Подключение 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 (исключения и фаталы) | бэкенд WordPress | sentry/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
Три вещи, которые определяют конструкцию плагина:
- Исключения никто не перехватывает. В отличие от Joomla, WordPress не
ловит исключения сам — обработчики, которые ставит
\Sentry\init(), работают напрямую. Отдельный код для отлова ошибок не нужен вообще. - Нет ни роутов, ни контроллеров. В Symfony имя транзакции берут из роута,
в Joomla — из
optionиview. В WordPress ничего этого нет: естьWP_Queryи набор условных тегов (is_singular(),is_archive()…). Имя транзакции придётся собирать по ним — и это самое интересное место. - Плагины живут в одном процессе. На типичном сайте их десятки, и каждый
тянет свой
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 | сайт лежит / сертификат истекает через три дня |
Итог
- Плагин на ~250 строк подключает
sentry/sentryи закрывает ошибки, трейсинг и Web Vitals. - Ошибки ловит сам SDK — WordPress исключения не перехватывает, в отличие от Joomla.
register_shutdown_functionвместо хукаshutdown— иначе при фатале транзакция не закроется.- Имя транзакции — тип страницы, а не URL:
single.post,archive.category,rest:/wp/v2/posts. vendor/обязательно скоупить php-scoper’ом, иначе конфликт версий с соседним плагином роняет весь сайт.
Дальше — документация, установка и раздел подключения SDK.