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
| Signal | Sent from | What you need |
|---|---|---|
| PHP errors (exceptions and fatals) | Drupal core | a service tagged logger |
| Response time per route | the module | an event subscriber on KernelEvents |
| JS errors and Web Vitals | browser | a library plus drupalSettings |
| Uptime and SSL | Gotcha, outbound | HTTP monitor, no code |
| Alerts | Gotcha | a 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
| Metric | Why |
|---|---|
| PHP errors per release | “500s started after that module update” |
| JS errors in the browser | a form or filter broken for a subset of visitors |
| p95 per route | shows 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 404s | a spike usually means broken links or scanners |
| Uptime and SSL expiry | the site is down / the certificate expires in three days |
Summary
- Errors come from a service tagged
logger: Drupal catches exceptions itself and hands them to every channel; nothing needs intercepting. - Levels arrive as RFC 5424 integers — without converting to PSR-3 strings the filter never matches.
- Placeholders must be substituted, or Gotcha receives the message template.
- Transaction names are route names,
entity.node.canonical; on 404 and 403 there is no route, so name it by the response code. - Asset order: initialisation is a separate library with a dependency, not
an inline snippet in
html_head. - Scope
vendor/, but exclude the contracts (Psr\Log, the Symfony interfaces) and the global\Drupalclass.
Next: the documentation, installation and the SDK setup section.