Errors and Monitoring in Production
Error pages without stack traces, logs written to stderr, exceptions grouped in Sentry, and an alert that fires before a user complains.
APP_ENV=prod php bin/console error:dump var/cache/prod/error_pages/ 404 500In the prod environment an uncaught exception produces a plain error page and a log entry. No stack trace reaches the browser. The page comes from a Twig template you can replace: templates/bundles/TwigBundle/Exception/error404.html.twig for 404s, error.html.twig for everything else. Write them before you need them, and test them in dev by importing the framework's error routes, which expose /_error/{statusCode}.
Logs go to stderr
Monolog is the logging library the framework integrates. Its production configuration writes to php://stderr rather than to a file, because a file inside a container disappears with the container while stderr is collected by the runtime. The default production handler is fingers_crossed: it buffers records and flushes the whole buffer only when one of them reaches action_level. You get the full trail that led to an error, and silence the rest of the time.
Reporting and alerting
An error tracker groups the same exception across requests, keeps the stack trace, and attaches the release it happened in. sentry/sentry-symfony does that; it reads SENTRY_DSN and the bundle is enabled only in prod by default.
Alert on symptoms, not on exceptions. A rising rate of 5xx responses, a readiness endpoint that starts failing, a queue that stops draining: those are things a user can feel. Every alert should name a runbook, and an alert nobody can act on should be deleted.
Example
// src/EventListener/ErrorContextListener.php
declare(strict_types=1);
namespace App\EventListener;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
// With no arguments, the event listened to is taken from the type of the
// __invoke() argument. The longhand is a services.yaml tag naming the
// event and the method; both register the same listener.
#[AsEventListener]
final class ErrorContextListener
{
public function __construct(private readonly LoggerInterface $logger)
{
}
public function __invoke(ExceptionEvent $event): void
{
$throwable = $event->getThrowable();
// An exception implementing HttpExceptionInterface carries the status
// code it should produce. Anything else becomes a 500.
$status = $throwable instanceof HttpExceptionInterface
? $throwable->getStatusCode()
: 500;
// A 404 is not a failure of the application. Do not page anyone.
if ($status < 500) {
return;
}
// The framework already logs the exception. This adds the fields the
// alert query needs, and leaves the response untouched.
$this->logger->error('Request failed', [
'exception' => $throwable,
'status' => $status,
'route' => $event->getRequest()->attributes->get('_route'),
'method' => $event->getRequest()->getMethod(),
]);
}
}When to use it
- Replace the default 404 and 500 pages with something that matches the site.
- Ship logs out of a container in a form a log store can query by field.
- Tie a sudden spike in a particular exception to the release that introduced it.
- Set up an alert on the 5xx rate that fires before the first support ticket arrives.
More examples
The production logging configuration
This is the shape a new project starts with, and it is worth reading line by line. excluded_http_codes stops a crawler hitting missing URLs from flushing a buffer full of debug records. buffer_size caps memory on a long-running worker. The JSON formatter matters once the logs reach a log store: one record per line, with the context array as real fields, is what makes a query like status:500 route:app_checkout possible.
# config/packages/prod/monolog.yaml
monolog:
handlers:
main:
type: fingers_crossed
# buffer everything, flush only when a record reaches this level
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50
nested:
type: stream
# not a file: a container's filesystem is not where logs live
path: php://stderr
level: debug
formatter: monolog.formatter.jsonSending exceptions to an error tracker
Install with composer require sentry/sentry-symfony. The dsn is mandatory and leaving it empty disables reporting, which is how you keep it quiet in local development. Setting release is the part people skip and then regret: without it, a new class of error that appeared twenty minutes ago cannot be attributed to the deploy that caused it. traces_sample_rate below 1 keeps performance data affordable on a busy site.
# config/packages/sentry.yaml
sentry:
dsn: '%env(SENTRY_DSN)%'
options:
environment: '%kernel.environment%'
# the release identifier, so a spike can be tied to a deploy
release: '%env(APP_RELEASE)%'
traces_sample_rate: 0.1An error page the user can act on
Add one per status code you care about, with error.html.twig as the fallback for the rest. Test them before a release by importing the framework error routes under when@dev, which gives you /_error/404 and /_error/500 in the browser; the prod templates are otherwise only exercised at the worst possible moment. These templates must not depend on a database or a logged-in user, because the reason they are rendering may be that one of those is unavailable.
{# templates/bundles/TwigBundle/Exception/error404.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>Page not found</h1>
<p>
That address does not exist. Check it for a typo, or
<a href="{{ path('app_home') }}">return to the homepage</a>.
</p>
{% endblock %}
Discussion