Подключение 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сайт лежит / сертификат истекает через три дня

Итог

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