ErrorReporter — Rate-Limited Exception Emails

ErrorReporter — Rate-Limited Exception Emails

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 28 of 35 Level: Intermediate Prerequisites: Request Logger MiddlewareNotifications and Mail


What You’ll Learn

  • Why exception notification needs rate-limiting
  • The Cache::add() deduplication pattern
  • Building the ErrorReporter static service
  • The ExceptionOccurred Mailable with full context
  • Wiring into Laravel’s exception handler
  • Sensitive field masking

The Problem with Naive Exception Emails

If you wire a simple exception email into bootstrap/app.php like this:

$exceptions->report(function (Throwable $e): void {
    Mail::to('dev@example.com')->send(new ExceptionOccurred($e));
});

One runtime error — say, a database timeout during a traffic spike — fires that email 300 times per minute. Your inbox is flooded. The mail queue backs up. The server’s mail rate limit kicks in. And you still don’t know what caused the spike.

The ErrorReporter solves this with a deduplication window: the same exception only fires one email per 5 minutes, regardless of how many times it occurs.


The Deduplication Key

The key insight: Cache::add() only sets a value if the key doesn’t already exist. It returns true if the key was newly set, false if it already existed:

// Only proceeds on the FIRST occurrence within 5 minutes
if (! Cache::add($dedupKey, true, now()->addMinutes(5))) {
    return; // already reported this exception recently
}

This is simpler and more reliable than Cache::get() + Cache::put(), because add() is atomic — no race condition between the check and the set.


The Full ErrorReporter

// app/Services/Core/ErrorReporter.php
<?php

declare(strict_types=1);

namespace App\Services\Core;

use App\Http\Middleware\RequestLogger;
use App\Mail\ExceptionOccurred;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;

final class ErrorReporter
{
    private const DEDUP_MINUTES = 5;

    public static function report(Throwable $e): void
    {
        // 1. Bail early if no recipients configured
        $recipients = self::recipients();
        if ($recipients === []) {
            return;
        }

        // 2. Rate-limit: skip if we reported this same exception recently
        $dedupKey = 'exception:' . hash('sha256',
            $e::class . $e->getMessage() . $e->getFile() . $e->getLine()
        );

        if (! Cache::add($dedupKey, true, now()->addMinutes(self::DEDUP_MINUTES))) {
            Log::channel('error')->debug('Exception suppressed (dedup)', [
                'class'   => $e::class,
                'message' => $e->getMessage(),
            ]);
            return;
        }

        // 3. Log the exception to the dedicated error channel
        Log::channel('error')->error('Exception reported', [
            'class'      => $e::class,
            'message'    => $e->getMessage(),
            'file'       => $e->getFile(),
            'line'       => $e->getLine(),
            'request_id' => request()->header(RequestLogger::X_REQUEST_ID),
            'url'        => request()->fullUrl(),
            'user_id'    => auth()->id(),
        ]);

        // 4. Build the payload and send the email
        $payload = self::buildPayload($e);

        try {
            Mail::to($recipients)->send(new ExceptionOccurred($payload));
        } catch (Throwable $mailException) {
            // If mail fails, at least log it
            Log::channel('error')->critical('ErrorReporter failed to send email', [
                'mail_error' => $mailException->getMessage(),
            ]);
        }
    }

    /**
     * @return array<string>
     */
    private static function recipients(): array
    {
        $configured = config('app.error_report_emails', []);
        return array_filter(
            is_array($configured) ? $configured : [$configured],
            fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)
        );
    }

    /**
     * @return array<string, mixed>
     */
    private static function buildPayload(Throwable $e): array
    {
        $request = request();

        return [
            'environment'     => app()->environment(),
            'exception_class' => $e::class,
            'message'         => $e->getMessage(),
            'file'            => $e->getFile(),
            'line'            => $e->getLine(),
            'trace'           => $e->getTraceAsString(),
            'url'             => $request->fullUrl(),
            'method'          => $request->method(),
            'input'           => self::sanitizeInput($request->all()),
            'user_id'         => auth()->id(),
            'user_type'       => self::resolveGuard(),
            'ip_address'      => $request->ip(),
            'user_agent'      => $request->userAgent(),
            'request_id'      => $request->header(RequestLogger::X_REQUEST_ID),
            'reported_at'     => now()->toISOString(),
        ];
    }

    /**
     * @param array<string, mixed> $input
     * @return array<string, mixed>
     */
    private static function sanitizeInput(array $input): array
    {
        $sensitive = config('error_reporting.sensitive_fields', [
            'password',
            'password_confirmation',
            'current_password',
            'token',
            'secret',
            'api_key',
            'card_number',
            'cvv',
        ]);

        foreach ($sensitive as $field) {
            if (array_key_exists($field, $input)) {
                $input[$field] = '[REDACTED]';
            }
        }

        return $input;
    }

    private static function resolveGuard(): ?string
    {
        foreach (['admin', 'merchandiser', 'employee', 'web'] as $guard) {
            if (auth($guard)->check()) {
                return $guard;
            }
        }
        return null;
    }
}

The ExceptionOccurred Mailable

// app/Mail/ExceptionOccurred.php
class ExceptionOccurred extends Mailable
{
    public function __construct(
        private readonly array $payload
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "[{$this->payload['environment']}] {$this->payload['exception_class']}",
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.exception-occurred',
            with: ['payload' => $this->payload],
        );
    }
}

The view renders: exception class, message, file/line, stack trace (collapsible), URL, method, sanitized input, user info, request_id, and environment. Everything you need to debug without SSH access.


Wiring into the Exception Handler

In bootstrap/app.php (Laravel 11):

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->report(function (Throwable $e): void {
        if (! app()->isProduction()) {
            return; // Only email on production
        }

        // Skip common non-errors
        if ($e instanceof \Illuminate\Auth\AuthenticationException) {
            return;
        }
        if ($e instanceof \Illuminate\Validation\ValidationException) {
            return;
        }
        if ($e instanceof \Symfony\Component\HttpKernel\Exception\NotFoundHttpException) {
            return;
        }

        ErrorReporter::report($e);
    });
})

Configuration

// config/app.php
'error_report_emails' => env('ERROR_REPORT_EMAILS', ''),
// In .env: ERROR_REPORT_EMAILS=dev@example.com,oncall@example.com

// config/error_reporting.php
return [
    'sensitive_fields' => [
        'password', 'password_confirmation', 'current_password',
        'token', 'api_key', 'secret', 'card_number', 'cvv',
    ],
];

Key Takeaways

  • Cache::add() is atomic — use it (not get + put) for dedup logic.
  • The dedup key includes exception class + message + file + line — enough specificity without being too granular.
  • Log to error channel AND email — the log is for correlation, the email is for alerting.
  • Sanitize input before including it in the email payload — never email passwords or tokens.
  • Skip 401/403/404/422 exceptions — they’re user errors, not application errors.
  • Wrap the Mail::send() call itself in try/catch — a broken mail connection should not prevent the error from being logged.

Tips and Gotchas

⚠️ Warning: The dedup key includes file + line number. If you deploy a refactor that moves exception-throwing code to a different line, the dedup key changes and the first occurrence after deploy fires a new email. This is correct behaviour — it’s a new location — but worth knowing during post-deploy monitoring.

💡 Tip: Test your ErrorReporter in staging by temporarily setting DEDUP_MINUTES = 0 or calling Cache::flush(). Then trigger a deliberate exception and verify the email arrives with the correct payload, sanitized input, and request_id.

🔥 Expert Note: The Cache::add() deduplication pattern is universally applicable: rate-limiting any one-per-N-minutes action, ensuring an idempotency key is only processed once, or preventing duplicate webhook acknowledgements. Learn it once, use it everywhere.

Further Reading


← Request Logger Middleware | Next: Slow Query Detection →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.