Подключение Gotcha к Drupal: ошибки через logger-канал и транзакции по роутам
Gotcha принимает данные по протоколу приёма Sentry, поэтому подключение
любого PHP-приложения — это официальный Sentry SDK, нацеленный на ваш инстанс.
Drupal из всех CMS — самый приятный случай: у него есть настоящий контейнер
сервисов, настоящий роутинг и штатная система логирования на PSR-3. Всё, что в
Joomla и Битриксе приходится делать обходными путями, здесь укладывается в две
записи в services.yml.
Разберём модуль целиком: ошибки через logger-канал, транзакции по именам роутов, браузерный SDK через систему библиотек. Готовый архив — gotcha_monitoring-1.0.0.zip, проверен на Drupal 11 с PHP 8.5.
Что будем собирать
| Сигнал | Откуда шлётся | Что нужно |
|---|---|---|
| Ошибки PHP (исключения и фаталы) | ядро Drupal | сервис с тегом logger |
| Время ответа по роутам | модуль | event subscriber на KernelEvents |
| Ошибки JS и Web Vitals | браузер | библиотека + drupalSettings |
| Аптайм и 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 в нём публичен по
замыслу. В модуле он станет настройкой: пустое значение — модуль полностью
выключен.
Ошибки: стать одним из каналов логирования
В Joomla исключения приходится вылавливать из события, в Битриксе — реализовать
класс лога и прописать его в конфиге. В Drupal всё проще и честнее: ядро уже
ловит и исключения (ExceptionLoggingSubscriber), и ошибки PHP, и передаёт их
всем сервисам с тегом logger. Нужно просто стать одним из них.
# gotcha_monitoring.services.yml
services:
# Тег logger подключает сервис к системе логирования Drupal: сюда приходят
# и записи watchdog, и необработанные исключения из ExceptionLoggingSubscriber.
gotcha_monitoring.logger:
class: Drupal\gotcha_monitoring\Logger\GotchaLogger
arguments: ['@config.factory', '@logger.log_message_parser']
tags:
- { name: logger }
Сам класс реализует Psr\Log\LoggerInterface. Drupal даёт трейт
RfcLoggerTrait, который сводит все методы (error(), critical(), …) к
одному log():
final class GotchaLogger implements LoggerInterface {
use RfcLoggerTrait;
private const REPORTED = [
LogLevel::EMERGENCY,
LogLevel::ALERT,
LogLevel::CRITICAL,
LogLevel::ERROR,
];
public function log($level, string|\Stringable $message, array $context = []): void {
$level = $this->normalizeLevel($level);
if (!in_array($level, self::REPORTED, TRUE)) {
return;
}
// Своё же логирование не отправляем: иначе ошибка отправки породит новую
// запись, та — новую отправку, и так по кругу.
if (($context['channel'] ?? '') === 'gotcha_monitoring') {
return;
}
if (!Sdk::init($this->configFactory)) {
return;
}
$exception = $context['exception'] ?? NULL;
\Sentry\withScope(function (Scope $scope) use ($context, $level, $message, $exception): void {
$scope->setTag('drupal.channel', (string) ($context['channel'] ?? 'php'));
if ($exception instanceof \Throwable) {
\Sentry\captureException($exception);
return;
}
// Без исключения в контексте остаётся текст с плейсхолдерами Drupal
// (@message, %type) — подставляем их, иначе в Gotcha приедет шаблон.
$placeholders = $this->parser->parseMessagePlaceholders($message, $context);
$text = empty($placeholders)
? (string) $message
: strtr((string) $message, $placeholders);
\Sentry\captureMessage($text, \Sentry\Severity::fromError($level));
});
}
}
Три вещи, которые легко упустить.
Уровни приходят числами. Drupal передаёт константы RfcLogLevel (0–7 по
RFC 5424), а PSR-3 ждёт строки вроде error. Без преобразования сравнение с
уровнями не сработает никогда, и модуль будет молчать:
private function normalizeLevel(mixed $level): string {
if (is_string($level)) {
return $level;
}
return match ((int) $level) {
0 => LogLevel::EMERGENCY,
1 => LogLevel::ALERT,
2 => LogLevel::CRITICAL,
3 => LogLevel::ERROR,
4 => LogLevel::WARNING,
5 => LogLevel::NOTICE,
6 => LogLevel::INFO,
default => LogLevel::DEBUG,
};
}
Плейсхолдеры. Сообщения Drupal — это шаблоны: %type: @message в %function (строка %line из %file). Если отправить их как есть, в Gotcha приедет шаблон
вместо текста ошибки, и все события схлопнутся в один issue. Поэтому сервис
logger.log_message_parser и подстановка. Когда в контексте есть само
исключение — берём его, там и стектрейс.
Защита от петли. Отправка в Gotcha может сама упасть (сеть, таймаут) — Drupal залогирует это, логгер снова попробует отправить, и так по кругу. Фильтр по каналу разрывает цикл.
Транзакции: у Drupal есть настоящие роуты
Здесь Drupal выигрывает у остальных CMS. В WordPress имя транзакции приходится
собирать из условных тегов, в Битриксе — брать физический скрипт. У Drupal есть
роутинг, и имя роута — ровно та единица, которая нужна:
entity.node.canonical — одна строка в отчёте на все ноды сайта, а не по
строке на каждый URL.
final class TransactionSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents(): array {
return [
// Приоритет выше маршрутизатора: транзакция должна охватить и её.
KernelEvents::REQUEST => ['onRequest', 1000],
KernelEvents::TERMINATE => ['onTerminate', -1000],
];
}
public function onRequest(RequestEvent $event): void {
if (!$event->isMainRequest() || $this->transaction !== NULL) {
return;
}
if (!Sdk::init($this->configFactory)) {
return;
}
$this->transaction = \Sentry\startTransaction(
TransactionContext::make()->setName('http.request')->setOp('http.server')
);
SentrySdk::getCurrentHub()->setSpan($this->transaction);
// Не только TERMINATE: при фатальной ошибке до него дело не доходит,
// а shutdown-функция PHP вызывается всегда.
register_shutdown_function([$this, 'finish']);
}
}
Транзакция стартует на REQUEST с приоритетом выше маршрутизатора — чтобы в
неё попало и время маршрутизации. Имя роута к этому моменту ещё неизвестно,
поэтому уточняем его на TERMINATE:
private function transactionName(TerminateEvent $event): string {
$route = (string) $event->getRequest()->attributes->get('_route', '');
if ($route !== '') {
return $route;
}
// На 404 и 403 роут не подобран вовсе — называем по коду ответа, иначе
// весь мусорный трафик слипается в одну строку «unknown».
$status = $event->getResponse()->getStatusCode();
return $status >= 400 ? (string) $status : 'unknown';
}
В отчёте это выглядит так — сразу видно, что где:
view.frontpage.page_1 главная (Views)
entity.node.canonical страницы нод
user.login форма входа
system.admin админка
404 ненайденные адреса
Обратите внимание на system.admin со статусом 403: роут подобран, доступ
запрещён. Это полезнее, чем 403, — видно, куда именно ломятся.
Браузерный SDK: библиотеки и drupalSettings
У Drupal своя система ассетов, и здесь есть ловушка с порядком загрузки.
Соблазнительно вставить Sentry.init(...) инлайном через
$attachments['#attached']['html_head'] — но html_head рендерится раньше
JS-библиотек, и в этот момент Sentry ещё не определён.
Правильно — объявить две библиотеки, вторая зависит от первой:
# gotcha_monitoring.libraries.yml
browser:
version: 1.0.0
js:
# preprocess: false — бандл уже минифицирован, агрегатор Drupal его только
# замедлит; header: true — SDK должен стартовать до остального JS.
js/sentry.min.js: { minified: true, preprocess: false }
header: true
init:
version: 1.0.0
js:
js/gotcha-init.js: { preprocess: false }
header: true
dependencies:
- gotcha_monitoring/browser
- core/drupalSettings
Настройки уезжают в JS штатным способом — через drupalSettings:
function gotcha_monitoring_page_attachments(array &$attachments): void {
$config = \Drupal::config('gotcha_monitoring.settings');
$dsn = trim((string) $config->get('dsn'));
if ($dsn === '' || !$config->get('browser')) {
return;
}
$attachments['#attached']['library'][] = 'gotcha_monitoring/init';
$attachments['#attached']['drupalSettings']['gotchaMonitoring'] = [
'dsn' => $dsn,
'environment' => (string) ($config->get('environment') ?: 'production'),
'tracesSampleRate' => (float) $config->get('traces_sample_rate'),
];
}
А инициализация — обычный файл, который читает эти настройки:
(function (Sentry, drupalSettings) {
'use strict';
var settings = drupalSettings.gotchaMonitoring;
if (!settings || !settings.dsn || typeof Sentry === 'undefined') {
return;
}
Sentry.init({
dsn: settings.dsn,
environment: settings.environment,
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: settings.tracesSampleRate
});
})(window.Sentry, window.drupalSettings);
browserTracingIntegration сам снимает Web Vitals (LCP, CLS, INP, FCP,
TTFB) и ловит JS-ошибки.
Настройки
Форма — штатный ConfigFormBase, конфиг — config/install/…settings.yml плюс
схема в config/schema/. Схему нужно писать обязательно: без неё Drupal
ругается на непокрытые ключи конфигурации при экспорте и в тестах.
# config/schema/gotcha_monitoring.schema.yml
gotcha_monitoring.settings:
type: config_object
label: 'Gotcha Monitoring settings'
mapping:
dsn:
type: string
label: 'DSN'
traces_sample_rate:
type: float
label: 'Traces sample rate'
Роут формы объявляется в gotcha_monitoring.routing.yml, пункт меню — в
gotcha_monitoring.links.menu.yml, а ключ configure: в info.yml добавляет
ссылку «Настроить» прямо в списке модулей.
Обязательный шаг: изоляция vendor
Модуль несёт свой vendor/, и ядро Drupal тоже — там psr/log, guzzle,
symfony/*. Классы называются одинаково, версии разные, побеждает автолоадер,
сработавший первым. Лечится префиксом через
php-scoper, но с Drupal есть важная
тонкость.
Контракты префиксовать нельзя. Наш логгер обязан реализовывать именно
непрефиксный Psr\Log\LoggerInterface, а подписчик — непрефиксный
Symfony\Component\EventDispatcher\EventSubscriberInterface: иначе Drupal
просто не увидит в них ни логгер, ни event subscriber. Поэтому эти namespace
исключаются из префиксования:
return [
'prefix' => 'Drupal\\gotcha_monitoring\\Vendor',
'finders' => [
Finder::create()->files()->in('vendor'),
Finder::create()->files()->name('*.php')->in('src'),
],
'exclude-namespaces' => [
'Drupal',
// Контракты ядра: логгер должен реализовывать именно НЕпрефиксный
// Psr\Log\LoggerInterface, а подписчик — интерфейсы Symfony,
// иначе Drupal не увидит в них ни логгер, ни event subscriber.
'Psr\\Log',
'Symfony\\Component\\EventDispatcher',
'Symfony\\Component\\HttpKernel',
],
// \Drupal — глобальный класс, а не namespace: без этой строки
// php-scoper переписывает \Drupal::VERSION в префиксный класс,
// и сайт падает с "Class ... \Vendor\Drupal not found".
'exclude-classes' => ['Drupal'],
];
Последние две строки — отдельная грабля, на которой мы и споткнулись при
первой сборке. \Drupal — это глобальный класс, а не namespace Drupal\,
и правило exclude-namespaces его не покрывает. Без exclude-classes вызов
\Drupal::VERSION превращается в
Drupal\gotcha_monitoring\Vendor\Drupal::VERSION, и сайт отдаёт 500 на каждой
странице:
Uncaught PHP Exception Error: Class "Drupal\gotcha_monitoring\Vendor\Drupal" not found
Раз Psr\Log мы всё равно не префиксуем, свою копию можно просто не тащить:
Drupal 10 и 11 несут psr/log 3.0.2, а Sentry SDK совместим с ней. В
composer.json это одна строка:
"replace": { "psr/log": "*" }
А вот symfony/options-resolver, который тянет Sentry, в ядре Drupal
отсутствует — его мы несём сами, уже с префиксом.
Установка
unzip gotcha_monitoring-1.0.0.zip -d /path/to/drupal/web/modules/custom/
drush en gotcha_monitoring
Или через админку: Расширения → Установить новый модуль, затем включить. Настройки — Конфигурация → Разработка → Gotcha Monitoring (или ссылка «Настроить» прямо в списке модулей).
Проверка: обратитесь к маршруту, который бросает исключение, — событие появится в разделе «Проблемы» через несколько секунд.
Аптайм и алерты
Аптайм не требует правок в коде — Gotcha сам ходит на публичный URL снаружи: Аптайм → Новый монитор → HTTP, URL сайта, интервал и пороги. Инциденты, SSL-алерты и публичная status-страница — из коробки (Аптайм).
В разделе «Оповещения» правила (новый issue, регрессия, всплеск) уже включены; остаётся добавить канал доставки — Telegram, webhook или email (Алерты).
Что смотреть на сайте под Drupal в первую очередь
| Метрика | Зачем |
|---|---|
| Ошибки PHP по релизам | «после обновления модуля посыпались 500-е» |
| Ошибки JS в браузере | форма или фильтр сломаны у части посетителей |
| p95 по роутам | видно, что медленно: нода, вьюха, поиск, админка |
| Web Vitals (LCP, INP, CLS) | реальная скорость у людей, а не в Lighthouse |
| Доля 403 и 404 | всплеск обычно означает битые ссылки или сканеры |
| Аптайм и срок SSL | сайт лежит / сертификат истекает через три дня |
Итог
- Ошибки — сервисом с тегом
logger: Drupal сам ловит исключения и раздаёт их всем каналам, перехватывать ничего не нужно. - Уровни приходят числами RFC 5424 — без преобразования в строки PSR-3 фильтр не сработает ни разу.
- Плейсхолдеры надо подставлять, иначе в Gotcha приедет шаблон сообщения.
- Имя транзакции — имя роута,
entity.node.canonical; на 404 и 403 роута нет, называем по коду ответа. - Порядок ассетов: инициализация — отдельная библиотека с зависимостью, а
не инлайн в
html_head. vendor/скоупить, но контракты (Psr\Log, интерфейсы Symfony) и глобальный класс\Drupal— исключать.
Дальше — документация, установка и раздел подключения SDK.