Search by

j87nl / laravel-error-reporter

J87.nl

Reports Laravel and browser errors to the J87 error collector.

Package info

github.com/J87NL/laravel-error-reporter

pkg:composer/j87nl/laravel-error-reporter

Statistics

Installs: 734

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.5.0 2026-10-26 07:42 UTC

This package is auto-updated.

Last update: 2026-10-30 07:41:40 UTC


README

Reports Laravel and browser errors to the J87 error collector on j87.nl.

Installation

composer require j87nl/laravel-error-reporter

Set the collector address and the keys the collector's admin shows once when you add the app:

ERROR_REPORTER_ENDPOINT=https://j87.nl/api/ingest/errors
ERROR_REPORTER_KEY=srv_...
ERROR_REPORTER_BROWSER_KEY=pub_...
APP_RELEASE=

APP_RELEASE is the release of the deploy; set it with the AppRelease helper. While the endpoint or the server key is empty, the default transport queues and sends nothing.

To change more than the environment variables, publish the config:

php artisan vendor:publish --tag=error-reporter-config
Environment variable Config key Default
ERROR_REPORTER_ENABLED enabled true
ERROR_REPORTER_ENDPOINT endpoint none
ERROR_REPORTER_KEY key none
ERROR_REPORTER_BROWSER_KEY browser_key none
ERROR_REPORTER_QUEUE_CONNECTION queue.connection the default
ERROR_REPORTER_QUEUE queue.name the default
ERROR_REPORTER_LOG_CHANNEL log_channel the default
APP_RELEASE release none
APP_ENV environment production
ERROR_REPORTER_REDACT_SECRETS redact.secrets true
ERROR_REPORTER_REDACT_PERSONAL_DATA redact.personal_data true
ERROR_REPORTER_IP ip masked
ERROR_REPORTER_INCLUDE_REQUEST_BODY include_request_body true
timeout (seconds) 2
max_frames 30
max_message_length 2000
max_context_bytes 8192
max_request_body_bytes 16384
request_body_exclude Fortify auth routes
scrub see below
redact.query_parameters see below
redact.patterns []
ignore (exception classes) []
ERROR_REPORTER_REPORT_FAILED_JOBS jobs.report_failures true
transport QueuedHttpTransport

What it sends

The package hooks into the exception handler. Every reported exception is still logged as usual; the package also queues one job that posts these fields to the collector:

  • fingerprint, exception class, exception code (when not 0), message, file and line;
  • a trimmed stack trace, each frame marked in-app or vendor;
  • route, method, URL and referrer without the query string, user id, environment and release;
  • the client address, masked by default (ERROR_REPORTER_IP: masked zeroes the last part of an IPv4 address and cuts IPv6 to 48 bits, full sends it as it is, off sends nothing). It comes from $request->ip(), so trusted proxies are respected;
  • the request input (form fields or JSON, not the query string), with sensitive keys masked at any depth and the value rules applied, uploaded files as name, size and type only, and {"truncated": true} above max_request_body_bytes. Never for the paths and route names in request_body_exclude, by default the Fortify login, registration, password and two-factor routes. ERROR_REPORTER_INCLUDE_REQUEST_BODY=false turns it off;
  • the exception's context(), replaced by {"truncated": true} when its JSON is larger than max_context_bytes.

A QueryException is sent with its SQLSTATE code and its SQL, without the bindings and without the driver's message, which can contain the bound values.

The job uses the configured timeout and does not retry. When the collector is down or refuses the report, the report goes to the log instead. A failure inside the job itself, and a delivery job that times out or runs out of attempts, is never reported again.

The job is dispatched with beforeCommit(), so it never waits for an open database transaction. Whether it survives a rollback depends on the queue. With Redis, SQS or another queue outside the database, it does. With QUEUE_CONNECTION=database on the same database connection as the transaction, the job row is written inside that transaction and rolled back with it, so the report is lost. Point ERROR_REPORTER_QUEUE_CONNECTION at a queue outside the database, or at a database queue on a connection of its own, when that matters. With the sync queue the request waits for the collector, at most the timeout; use a real queue in production.

When the transport itself fails, for example because the queue cannot be reached, the package logs a warning with the scrubbed report, so the report is not lost.

Exceptions that the handler does not report never reach the package: exceptions in the handler's dontReport list, and exceptions with their own report() method that handles the reporting.

Failed jobs

A queued job that fails is reported too, also when it fails without passing the exception handler. When a job runs past its timeout, the worker kills its own process, so the handler never sees the TimeoutExceededException. The package listens to Laravel's JobFailed and JobTimedOut events, which the worker fires before it kills the process, and sends the report right there:

  • the exception class and message, with context.job holding the job's display name, queue, connection and attempts. The job's payload and arguments are never sent;
  • a timed-out attempt that will be retried is reported as a TimeoutExceededException too;
  • every timeout of one job lands in one group: for TimeoutExceededException and MaxAttemptsExceededException the fingerprint uses the class and the exact message (which holds the job name), not the stack trace, which depends on where the job happened to be stopped;
  • an exception is sent once: a job that throws and then fails is not sent again when the handler reports the same exception;
  • ignore, the handler's dontReport and enabled apply as usual. ERROR_REPORTER_REPORT_FAILED_JOBS=false turns it off.

The report is handed to the transport inside the worker before the kill. With the default transport that means the delivery job is on the queue before the process stops.

Scrubbing

Before anything leaves the app, every key containing password, token, secret, authorization, cookie or key is replaced by [scrubbed], at any depth. The match is a substring match, so key also masks keys like monkey and primary_key. Change the list in error-reporter.scrub.

Route parameters whose name matches the same list are masked in the reported URL. The same keys are masked in the request input.

Values are masked too, in the message, the URL, the referrer and every string key and value in the context and the request input, before the message is cut and before the fingerprint is computed, so errors that differ only in a masked value are grouped together:

  • Secrets, on by default (ERROR_REPORTER_REDACT_SECRETS): Bearer and Basic credentials, parameters with a scrub word or one of redact.query_parameters (code, auth, session, verifier, signature, apikey, sessionid, sessid, authcode) as a part of their name, passwords in URLs, base64: keys, JSON Web Tokens, and opaque strings of 32 or more characters. UUIDs, migration names, slugs, class names and compiled views stay readable.
  • Personal data, on by default (ERROR_REPORTER_REDACT_PERSONAL_DATA=false turns it off): e-mail addresses, IBANs (also spaced and foreign) and Dutch phone numbers.
  • Your own regular expressions in redact.patterns.

J87NL\ErrorReporter\ValueRedactor holds these rules, so a collector can apply them to what it receives. See docs/scrubbing.md.

Browser errors

resources/js/errorReporter.ts hooks app.config.errorHandler, error and unhandledrejection. It posts with the public browser key, at most ten distinct errors per page load. Path segments of 32 or more letters, digits, - and _, such as reset tokens, are masked in the reported URL. A rejection with something other than an Error is sent as its message property, a number or boolean as its value, and anything else only as its type, so objects are never sent whole. Installing it twice on the same app does nothing.

A network error (Inertia's HttpNetworkError, a failed fetch, an XHR Network Error) is only sent when the same kind happens three times within 60 seconds in one page load, so one dropped request during a deploy is ignored. Change this with the networkErrorThreshold and networkErrorWindowMs options. The script sends no client address and no user agent: the collector takes the address from the report request and applies the app's address setting, and keeps only the browser family and major version, such as Safari 18.

Point an alias at the script in vite.config.ts:

resolve: {
    alias: {
        '@error-reporter': fileURLToPath(
            new URL(
                './vendor/j87nl/laravel-error-reporter/resources/js',
                import.meta.url,
            ),
        ),
    },
},

and in tsconfig.json:

"paths": {
    "@error-reporter/*": [
        "./vendor/j87nl/laravel-error-reporter/resources/js/*"
    ]
}

Share the browser config in HandleInertiaRequests:

public function share(Request $request): array
{
    return [
        ...parent::share($request),
        'errorReporter' => fn (): ?array => app(ErrorReporter::class)->browserConfig(),
    ];
}

and install the script when the app is created:

import { installErrorReporter } from '@error-reporter/errorReporter';

installErrorReporter(app, page.props.errorReporter);

browserConfig() returns null while no endpoint or browser key is set, and the script then does nothing.

When the app sends a Content-Security-Policy, add the collector's origin to connect-src, for example connect-src 'self' https://j87.nl. Without it the browser blocks the report.

Heartbeats

Optionally, the package lets the collector watch your scheduler and queue workers. Every five minutes the scheduler pings its own heartbeat, and queues a small job per watched queue that pings that queue's heartbeat when a worker handles it. A ping that fails never throws; it logs one warning per outage, without the token.

Create the heartbeats in the j87.nl admin under Monitoring → Heartbeats, with an interval of 5 minutes and a grace of 5 (scheduler) or 10 (queue) minutes, and add the keys and the tokens the admin shows once:

ERROR_REPORTER_HEARTBEATS_ENABLED=true
ERROR_REPORTER_SCHEDULER_HEARTBEAT_KEY=shop-scheduler
ERROR_REPORTER_SCHEDULER_HEARTBEAT_TOKEN=hb_...
ERROR_REPORTER_QUEUE_HEARTBEAT_KEY=shop-queue
ERROR_REPORTER_QUEUE_HEARTBEAT_TOKEN=hb_...
Environment variable Config key Default
ERROR_REPORTER_HEARTBEATS_ENABLED heartbeats.enabled false
ERROR_REPORTER_HEARTBEAT_ENDPOINT heartbeats.endpoint derived from the error endpoint
ERROR_REPORTER_SCHEDULER_HEARTBEAT_KEY heartbeats.scheduler.key none
ERROR_REPORTER_SCHEDULER_HEARTBEAT_TOKEN heartbeats.scheduler.token none
ERROR_REPORTER_QUEUE_HEARTBEAT_KEY heartbeats.queues.default.key none
ERROR_REPORTER_QUEUE_HEARTBEAT_TOKEN heartbeats.queues.default.token none
ERROR_REPORTER_QUEUE_HEARTBEAT_CONNECTION heartbeats.queues.default.connection the default
ERROR_REPORTER_QUEUE_HEARTBEAT_QUEUE heartbeats.queues.default.queue the default
ERROR_REPORTER_EXTRA_QUEUES heartbeats.extra_queues none

The address is derived from ERROR_REPORTER_ENDPOINT by swapping /ingest/errors for /heartbeats. Watch more queues by naming them in ERROR_REPORTER_EXTRA_QUEUES, with a key and token per name in upper snake case. The queue defaults to the name:

ERROR_REPORTER_EXTRA_QUEUES=mailing
ERROR_REPORTER_MAILING_HEARTBEAT_KEY=shop-mailing-queue
ERROR_REPORTER_MAILING_HEARTBEAT_TOKEN=hb_...

Queues under heartbeats.queues in a published config still work and win on a name clash. See docs/heartbeats.md.

Deploying

J87NL\ErrorReporter\Deploy\AppRelease builds the release (<release name>-<short sha>[-dirty]) and writes APP_RELEASE into the .env contents. It is plain PHP, so deploy.php loads it without Laravel:

use J87NL\ErrorReporter\Deploy\AppRelease;

require_once __DIR__.'/vendor/j87nl/laravel-error-reporter/src/Deploy/AppRelease.php';

$release = AppRelease::name((string) get('release_name'), trim($commit), $isDirty);
file_put_contents(__DIR__.'/.env', AppRelease::writeInto($environment, $release));

See docs/deploying.md.

Other transports

Bind your own J87NL\ErrorReporter\Contracts\ErrorTransport through error-reporter.transport, for example to send reports somewhere other than the collector.

Testing

composer install
vendor/bin/pest
node --test 'tests/js/*.test.ts'