Expose a public API to your customers and a new question lands on you: what are they actually doing with it, and what are they getting back? Sentry will flag when your own code throws, but it stays quiet on the rest. It won't tell you a customer has spent all week getting 400s from one endpoint, and it can't hand you the exact request behind the ticket they just opened.
So log every request they make, not just the ones that crash.
A request log answers the questions Sentry can't:
The approach is two Symfony kernel listeners writing to your own tables: one on every request, one on every exception.
They hook two different kernel events because they do two different jobs.
The first listens on kernel.request. It runs on every request, before your controller, and captures the context while it's all still in front of you: method, path, the user, the headers and body. It's also where you mint an id for the request and stash it, so anything downstream can point back at this exact row.
The second listens on kernel.exception, which Symfony only fires when something throws. Its job is the failure: record the status and the exception, link it back to the request that caused it, and hand the user something to quote at support.
Splitting them keeps each one honest. One always runs and only describes the request. The other runs only on failure and only describes the error. Cramming both into one listener means branching on "did anything break?" on every single request, which is exactly what the kernel events already tell you for free.
Sentry is still your debugger. It gets the stack trace, the breadcrumbs, the local variables, everything you need to work out why a request blew up. The exception listener replaces none of that. It runs alongside Sentry's own listener and writes one analytical row.
That division is the whole point. Sentry answers "what broke and where". The request log answers "how often, to whom, and on which endpoint": the questions you ask with a GROUP BY, not a stack trace. Keep capturing to Sentry exactly as you do now; this adds the layer Sentry was never meant to give you.
Storage and shape are both your call. Persist a row however your app already does it: a Doctrine entity, a raw DBAL insert, whatever you already reach for. And capture whatever you'll actually want to query later. For a request that's usually a uuid, the method and path, the caller, the headers and body, and a timestamp; for an exception, the status code, the exception class, the message, and the uuid of the request behind it. Drop what you don't need, add what you do.
The request listener:
#[AsEventListener(event: RequestEvent::class)]
final class RequestLogListener
{
public function __invoke(RequestEvent $event): void
{
// kernel.request also fires for sub-requests: internal forwards, ESI
// fragments, the error-page render. Guarding for the main request keeps
// you to one row per real client call instead of every internal hop.
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
// Mint the id up front and stash it on the request, so the exception
// listener can point its row back at this one.
$uuid = Uuid::v7()->toRfc4122();
$request->attributes->set('_request_log_uuid', $uuid);
// Strip auth headers before they ever reach the table. A request log is
// the easiest place to accidentally persist a token in plaintext.
$headers = $this->redactSecrets($request->headers->all());
$this->requestLog->store($uuid, $request, $headers);
}
}
The exception listener:
#[AsEventListener(event: ExceptionEvent::class)]
final class ExceptionLogListener
{
public function __invoke(ExceptionEvent $event): void
{
if (!$event->isMainRequest()) {
return; // same reasoning: only the top-level failure
}
$request = $event->getRequest();
$exception = $event->getThrowable();
// The uuid the request listener left behind, so the two rows join up.
$uuid = $request->attributes->get('_request_log_uuid');
$this->exceptionLog->store($uuid, $exception);
// Hand the user a reference, but only on a 5xx. A 4xx already carries a
// meaningful body you don't want to overwrite.
if ($this->isServerError($exception)) {
$event->setResponse(new JsonResponse([
'message' => 'Internal server error',
'identifier' => $uuid,
], 500));
}
}
}
That identifier is what the user gets back when something fails:
{
"message": "Internal server error",
"identifier": "0190f8c2-7b1e-7c4a-9f0c-2a1b3c4d5e6f"
}
It's the way back to the full request when they open a ticket about it.
The payoff is in the join. Error rate per endpoint over the last week:
SELECT r.path,
COUNT(*) AS requests,
COUNT(e.id) AS errors
FROM request_log r
LEFT JOIN exception_log e ON e.request_uuid = r.uuid
WHERE r.created_at > NOW() - INTERVAL 7 DAY
GROUP BY r.path
ORDER BY errors DESC;
Swap the WHERE for e.exception_type = '...' and you have the list of user_ids to mail once the fix is out.
This only logs what actually throws. If parts of your API return error codes without raising an exception, move the status capture into a response listener instead. Same idea, different hook.
The simplest store() is a direct insert, and for plenty of apps that's the right call. The cost is that you've put a database write in front of every request: if the log table is slow or the DB is down, you've tied that to requests that had nothing to do with it. A few ways to cut the coupling, in rough order of effort:
kernel.terminate. Symfony fires that event after the response is already flushed to the client, so even a plain insert stops counting against the user's latency. Cheapest win, no new infrastructure; you still pay for the write, you just don't make anyone wait for it.Start with the direct insert. Move it to kernel.terminate the moment it shows up in your latency, and reach for the queue only when you actually need the decoupling. Building the RabbitMQ path first is usually solving a problem you don't have yet.
Two listeners, two tables. Cheap to bolt on, and it turns "something broke for someone, sometime" into a query you can answer.