· Péhápkaři Prague meetup #39 · Prague · given in Czech

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.

Update, 2026The idea still works, the code needs changes. Since sentry-php 3.0, 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:

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 getsentry/sentry-php 2.2 milestone on GitHub, 71% complete. Four of the listed pull requests are by JanMikes: Make use of Event::$logger (closed), Possibility to add transaction to record (closed), Add channel to payload as logger (merged), Added support for setting transaction name via monolog extra context (merged).
My pull requests in the sentry-php 2.2 milestone.

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
  1. Slide 1: Title: Sentry 2.0 v Nette 3.0, 8 August 2019
  2. Slide 2: Entrydo, GLAMI, Péhápkaři.cz, Rector
  3. Slide 3: Sentry issue list with test exceptions
  4. Slide 4: sentry/sentry-symfony
  5. Slide 5: Options: contributte/logging, salamek/raven-nette, Monolog with Raven handler, Monolog with Sentry handler
  6. Slide 6: contributte/logging crossed out
  7. Slide 7: salamek/raven-nette crossed out
  8. Slide 8: Monolog with Raven handler crossed out
  9. Slide 9: All four options crossed out
  10. Slide 10: sentry-php 2.2 milestone with Jan's pull requests
  11. Slide 11: Pull request #853 reverting transaction name extraction in the Monolog handler
  12. Slide 12: And the winner is...
  13. Slide 13: The winner: sentry/sdk
  14. Slide 14: Quickstart: Sentry\init in Bootstrap
  15. Slide 15: Presenter throwing an exception in startup
  16. Slide 16: The exception as a Sentry event
  17. Slide 17: Happy Friday? Still only Thursday
  18. Slide 18: Bootstrap with the hardcoded DSN highlighted
  19. Slide 19: Caught exception logged with Debugger::log
  20. Slide 20: Handwritten notes
  21. Slide 21: Handwritten notes on Nette Application events and logging
  22. Slide 22: Meet Tracy!
  23. Slide 23: Holding hands
  24. Slide 24: Looking under the hood
  25. Slide 25: Option 1: logging directly
  26. Slide 26: Option 2: uncaught exception
  27. Slide 27: Bootstrap::boot()->createContainer()->getByType(Application)->run()
  28. Slide 28: Application::run() source
  29. Slide 29: Application::run() calling processException
  30. Slide 30: ErrorPresenter logging the exception
  31. Slide 31: Option 3: uncaught uncaught exception, rethrown from run()
  32. Slide 32: enableTracy() in Bootstrap
  33. Slide 33: Debugger::enable registering exceptionHandler, which logs in production mode
  34. Slide 34: Heureka!
  35. Slide 35: Debugger::log calls getLogger()->log
  36. Slide 36: Debugger::setLogger and getLogger
  37. Slide 37: Tracy DI extension adding Debugger::setLogger for a custom tracy.logger
  38. Slide 38: Not sure if the solution works
  39. Slide 39: Decorated logger: parent log plus logToSentry
  40. Slide 40: services: tracy.logger: App\Logging\SentryDecoratedTracyLogger
  41. Slide 41: Generated container calling Debugger::setLogger
  42. Slide 42: Mystery solved
  43. Slide 43: The final logger class and NEON config
  44. Slide 44: NEON parameters for Sentry
  45. Slide 45: Logger constructor and log method
  46. Slide 46: logToSentry building the payload
  47. Slide 47: Mapping Tracy priorities to Sentry severities
  48. Slide 48: The caught exception in Sentry
  49. Slide 49: Production SentryTracyLogger with event processors
  50. Slide 50: github.com/janmikes/nette-sentry-demo
  51. Slide 51: GLAMI and Rector: we want you
  52. Slide 52: Thank you! Questions?

Related talks