Sentry 2.0 in Nette 3.0
Sentry 2.0 v Nette 3.0. Getting the first error into Sentry takes one line. Getting every error there means understanding how Tracy, Nette’s debugger, logs things. Once you do, the fix is one service in the config.
captureEvent() takes an Event object, not the array used below. The sentry/sdk metapackage is no longer recommended; install sentry/sentry directly. And there’s now contributte/sentry, a maintained Nette integration you may want instead of writing your own.Picking a package
Sentry is a service that collects errors from your app, groups them, and shows each one with its stack trace and context. In 2019 there were several ways to connect it to a PHP app:
sentry/sentry-symfony, the official bundle, but only for Symfonycontributte/loggingsalamek/raven-nette- Monolog with the old Raven handler
- Monolog with the new Sentry handler
I crossed them out one by one. The winner was the plain sentry/sdk, with a few lines of my own glue.
Along the way I sent a few pull requests to getsentry/sentry-php to improve its Monolog handler. Adding the channel to the payload as logger and setting the transaction name from Monolog’s extra context were merged into the 2.2 milestone; two others were closed.
The transaction one didn’t last. The maintainers reverted it 20 days before the talk: key names like transaction are too generic and may not mean what Sentry means, and copying structured log data blindly into the event can make it very big or leak things you don’t want there.
The quickstart
The minimum is one call in Bootstrap::boot(), next to enableTracy():
$configurator->enableTracy(__DIR__ . '/../log');
\Sentry\init(['dsn' => 'https://xyz@sentry.io/1480147']);
Throw an exception in a presenter and it shows up in Sentry with the browser, PHP version, URL and the exact line. Done? Not quite. Wrap that exception in try/catch and log it the usual Nette way, and nothing arrives:
try {
throw new \Exception('This is demo!');
} catch (\Exception $e) {
Debugger::log($e, Debugger::CRITICAL);
}
Three ways an error reaches Tracy
Tracy is Nette’s debugger and logger. To catch everything, I had to find every path an error takes in a Nette app. There are three.
Option 1: logging directly. You call Debugger::log() yourself, as above.
Option 2: an uncaught exception. The app starts with:
App\Bootstrap::boot()
->createContainer()
->getByType(Nette\Application\Application::class)
->run();
Application::run() wraps the whole request in try/catch. When something throws, and catchExceptions is on with an error presenter set, it calls processException(). That forwards to the ErrorPresenter, which logs the exception with $this->logger->log($exception, ILogger::EXCEPTION).
Option 3: an uncaught uncaught exception. If there’s no error presenter, or exceptions aren’t caught, run() rethrows. Then Tracy steps in. Debugger::enable(), called by enableTracy(), registers its own set_exception_handler, and in production mode that handler calls self::log($exception, self::EXCEPTION).
All three end in the same place. Debugger::log() is just:
public static function log($message, string $priority = ILogger::INFO)
{
return self::getLogger()->log($message, $priority);
}
So the answer is to replace Tracy’s logger. Debugger::setLogger() exists, but where do you call it, and early enough?
Let the container do it
Nette’s Tracy DI extension already handles this. If the tracy.logger service isn’t the default Debugger::getLogger(), it adds a Tracy\Debugger::setLogger(...) call to the container’s initialization:
$logger = $builder->getDefinition($this->prefix('logger'));
if ($logger->getFactory()->getEntity() !== [Tracy\Debugger::class, 'getLogger']) {
$initialize->addBody($builder->formatPhp('Tracy\Debugger::setLogger(?);', [$logger]));
}
So all you do is register your own logger under that name. I checked the generated container to be sure, and the setLogger() call was there. Mystery solved.
The decorated logger
The logger decorates Tracy’s default one: it keeps the original file logging and also sends to Sentry. The config holds the Sentry options, with the DSN left empty so each environment sets it in local.neon:
parameters:
sentry:
dsn: '' # Change this in your local.neon
in_app_exclude:
- %appDir%/../vendor/
- %appDir%/../temp/
project_root: %appDir%/../
send_default_pii: true
services:
tracy.logger: App\Logging\SentryDecoratedTracyLogger(%sentry%)
And the class:
public function __construct(array $sentryOptions)
{
$this->parentLogger = Debugger::getLogger();
\Sentry\init($sentryOptions);
}
public function log($value, $priority = self::INFO): void
{
// Logging to default Tracy logger
$this->parentLogger->log($value, $priority);
$this->logToSentry($value, $priority);
}
private function logToSentry($value, $priority): void
{
$severity = $this->getSeverityFromPriority($priority);
if (!$severity) {
return;
}
$payload = [
'message' => $value,
'level' => $severity,
];
if ($value instanceof \Throwable) {
$payload['exception'] = $value;
$payload['message'] = $value->getMessage();
}
captureEvent($payload);
}
private function getSeverityFromPriority(string $priority): ?Severity
{
switch ($priority) {
case ILogger::WARNING:
return Severity::warning();
case ILogger::ERROR:
return Severity::error();
case ILogger::EXCEPTION:
case ILogger::CRITICAL:
return Severity::fatal();
default:
return null;
}
}
Info and debug messages stay in the log files. Warnings, errors, exceptions and critical errors go to Sentry too. With this in place, the caught exception from the beginning shows up in Sentry with its stack trace.
In production
The version we ran at GLAMI was the same idea with more context. The logger took a custom stack trace factory and registered several global event processors through Scope::addGlobalEventProcessor(): user context, transaction, system user, console command and country. Each one adds its piece of information to every event before it’s sent.
The full demo is on GitHub: github.com/janmikes/nette-sentry-demo.
All 52 slides
Related talks
Video (Facebook) Demo code on GitHub sentry-php Tracy All talks


















































