Connecting Gotcha to Drupal: errors through a logger channel, transactions by route

Gotcha ingests data over the Sentry ingest protocol, so connecting any PHP application means an official Sentry SDK pointed at your instance. Of all the CMSes, Drupal is the pleasant case: it has a real service container, real routing and a stock PSR-3 logging system. Everything that takes a workaround in Joomla and Bitrix fits into two entries in services.yml here.

Let’s walk through the whole module: errors through a logger channel, transactions named by route, the browser SDK through the library system. The prebuilt archive is gotcha_monitoring-1.0.0.zip, tested on Drupal 11 with PHP 8.5.

What we’ll collect

SignalSent fromWhat you need
PHP errors (exceptions and fatals)Drupal corea service tagged logger
Response time per routethe modulean event subscriber on KernelEvents
JS errors and Web Vitalsbrowsera library plus drupalSettings
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 module it becomes a setting: an empty value disables the module entirely.

Errors: become one of the logging channels

In Joomla you fish exceptions out of an event; in Bitrix you implement a log class and register it in the config. Drupal is simpler and more honest: the core already catches exceptions (ExceptionLoggingSubscriber) and PHP errors, and hands them to every service tagged logger. All you do is become one.

# gotcha_monitoring.services.yml
services:
  # The logger tag wires the service into Drupal's logging system: watchdog
  # entries and uncaught exceptions from ExceptionLoggingSubscriber both arrive here.
  gotcha_monitoring.logger:
    class: Drupal\gotcha_monitoring\Logger\GotchaLogger
    arguments: ['@config.factory', '@logger.log_message_parser']
    tags:
      - { name: logger }

The class itself implements Psr\Log\LoggerInterface. Drupal provides RfcLoggerTrait, which funnels every method (error(), critical(), …) into a single 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;
    }

    // Don't report our own logging: a failed send would create a log entry,
    // which would trigger another send, and around it goes.
    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;
      }

      // With no exception in the context the message is still a template with
      // Drupal placeholders (@message, %type) — substitute them, or Gotcha
      // receives the template rather than the text.
      $placeholders = $this->parser->parseMessagePlaceholders($message, $context);
      $text = empty($placeholders)
        ? (string) $message
        : strtr((string) $message, $placeholders);

      \Sentry\captureMessage($text, \Sentry\Severity::fromError($level));
    });
  }

}

Three things that are easy to miss.

Levels arrive as integers. Drupal passes RfcLogLevel constants (0–7 per RFC 5424) while PSR-3 expects strings like error. Without the conversion the level comparison never matches and the module stays silent forever:

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

Placeholders. Drupal messages are templates: %type: @message in %function (line %line of %file). Send them as they are and Gotcha receives the template instead of the error text, collapsing every event into a single issue. Hence the logger.log_message_parser service and the substitution. When the context carries the exception itself, take that — it has the stack trace.

Loop protection. Sending to Gotcha can itself fail (network, timeout); Drupal logs that, the logger tries to send again, and around it goes. Filtering on the channel breaks the cycle.

Transactions: Drupal has real routes

This is where Drupal beats the other CMSes. In WordPress the transaction name has to be assembled from conditional tags; in Bitrix you take the physical script. Drupal has routing, and the route name is exactly the unit you want: entity.node.canonical is one row in the report for every node on the site rather than one row per URL.

final class TransactionSubscriber implements EventSubscriberInterface {

  public static function getSubscribedEvents(): array {
    return [
      // Higher priority than the router: the transaction should cover it too.
      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);

    // Not just TERMINATE: on a fatal error it is never reached, while PHP
    // always calls its shutdown functions.
    register_shutdown_function([$this, 'finish']);
  }

}

The transaction starts on REQUEST at a priority above the router, so routing time lands inside it. The route name isn’t known yet at that point, so we refine the name on TERMINATE:

private function transactionName(TerminateEvent $event): string {
  $route = (string) $event->getRequest()->attributes->get('_route', '');

  if ($route !== '') {
    return $route;
  }

  // On 404 and 403 no route matched at all — name it by the response code, or
  // all the junk traffic collapses into a single "unknown" row.
  $status = $event->getResponse()->getStatusCode();

  return $status >= 400 ? (string) $status : 'unknown';
}

In the report it reads directly:

view.frontpage.page_1     the front page (Views)
entity.node.canonical     node pages
user.login                the login form
system.admin              admin area
404                       addresses that don't exist

Note system.admin with a 403: the route matched, access was denied. That is more useful than a bare 403 — you can see what people are trying to reach.

The browser SDK: libraries and drupalSettings

Drupal has its own asset system, and there is a load-order trap in it. It is tempting to inline Sentry.init(...) via $attachments['#attached']['html_head'] — but html_head renders before the JS libraries, and Sentry isn’t defined at that point.

The right way is to declare two libraries, the second depending on the first:

# gotcha_monitoring.libraries.yml
browser:
  version: 1.0.0
  js:
    # preprocess: false — the bundle is already minified, Drupal's aggregator
    # would only slow it down; header: true — the SDK must start before other 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

The settings travel to JS the stock way — through 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'),
  ];
}

And the initialisation is a plain file that reads them:

(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 collects Web Vitals (LCP, CLS, INP, FCP, TTFB) on its own and catches JS errors.

Settings

The form is a stock ConfigFormBase; the config lives in config/install/…settings.yml plus a schema in config/schema/. Write the schema — without it Drupal complains about unschemaed config keys on export and in tests.

# 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'

The form’s route goes in gotcha_monitoring.routing.yml, the menu entry in gotcha_monitoring.links.menu.yml, and the configure: key in info.yml adds a “Configure” link right in the modules list.

A required step: scoping the vendor directory

The module ships its own vendor/, and so does Drupal core — psr/log, guzzle, symfony/*. Identical class names, different versions, and whichever autoloader registered first wins. The fix is a prefix via php-scoper, but Drupal adds an important wrinkle.

The contracts must not be prefixed. Our logger has to implement the unprefixed Psr\Log\LoggerInterface, and the subscriber the unprefixed Symfony\Component\EventDispatcher\EventSubscriberInterface — otherwise Drupal simply won’t recognise either the logger or the event subscriber. So those namespaces are excluded from prefixing:

return [
    'prefix' => 'Drupal\\gotcha_monitoring\\Vendor',
    'finders' => [
        Finder::create()->files()->in('vendor'),
        Finder::create()->files()->name('*.php')->in('src'),
    ],
    'exclude-namespaces' => [
        'Drupal',
        // Core contracts: the logger must implement the UNprefixed
        // Psr\Log\LoggerInterface and the subscriber the Symfony interfaces,
        // or Drupal won't see a logger or an event subscriber in them.
        'Psr\\Log',
        'Symfony\\Component\\EventDispatcher',
        'Symfony\\Component\\HttpKernel',
    ],
    // \Drupal is a global class, not a namespace: without this line
    // php-scoper rewrites \Drupal::VERSION into a prefixed class and the
    // site dies with "Class ... \Vendor\Drupal not found".
    'exclude-classes' => ['Drupal'],
];

Those last two lines are a rake of their own, and we stepped on it on the first build. \Drupal is a global class, not the Drupal\ namespace, so the exclude-namespaces rule doesn’t cover it. Without exclude-classes the call to \Drupal::VERSION becomes Drupal\gotcha_monitoring\Vendor\Drupal::VERSION and the site serves a 500 on every page:

Uncaught PHP Exception Error: Class "Drupal\gotcha_monitoring\Vendor\Drupal" not found

Since we don’t prefix Psr\Log anyway, there is no reason to ship our own copy: Drupal 10 and 11 both carry psr/log 3.0.2 and the Sentry SDK is compatible with it. In composer.json that’s one line:

"replace": { "psr/log": "*" }

symfony/options-resolver, which Sentry pulls in, is not in Drupal core though — we ship that one ourselves, prefixed.

Installing

unzip gotcha_monitoring-1.0.0.zip -d /path/to/drupal/web/modules/custom/
drush en gotcha_monitoring

Or through the UI: Extend → Install new module, then enable it. Settings live under Configuration → Development → Gotcha Monitoring (or the “Configure” link right in the modules list).

To verify, hit a route that throws — 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 Drupal site

MetricWhy
PHP errors per release“500s started after that module update”
JS errors in the browsera form or filter broken for a subset of visitors
p95 per routeshows what is slow: a node, a view, search, the admin area
Web Vitals (LCP, INP, CLS)real speed for real people, not in Lighthouse
Share of 403s and 404sa spike usually means broken links or scanners
Uptime and SSL expirythe site is down / the certificate expires in three days

Summary

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