Skip to content

Laravel caught exceptions recorded only on a php-fpm worker's first request when the bound exception handler inherits report()/render() (PHP 8+/OAPI) #1254

Description

@leoralph

Summary

On PHP 8.0+ (Observer API), when a Laravel application's bound exception handler is a subclass that inherits report()/render() from Illuminate\Foundation\Exceptions\Handler without overriding them, the agent records the caught exception only on the first request handled by each php-fpm worker process. Every subsequent request served by the same (warm) worker is silently not recorded — no TransactionError, empty Errors inbox — even though the exception is thrown, handled, and logged normally by the app on every request.

This is exactly the classic app/Exceptions/Handler.php shipped by default in Laravel ≤ 10:

namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;

class Handler extends ExceptionHandler
{
    // no report()/render() override
}

When the framework handler (Illuminate\Foundation\Exceptions\Handler) is used directly — or when the subclass overrides report()/render() — every request records correctly. The problem is specific to the inherited method.

Impact

Because production php-fpm workers are long-lived (pm = dynamic/ondemand, pm.max_requests = 0), the vast majority of caught 5xx exceptions never reach APM: only the first exception per worker lifetime is recorded. The failure is invisible (you don't get alerted about errors you don't receive), and it affects any app still on the pre-Laravel-11 handler pattern. Laravel 11/12 removed app/Exceptions/Handler.php (framework handler by default), which is why newer apps are unaffected.

Environment

  • Agent: reproduced on 12.5.0.30, 12.6.0.34 and 12.8.0.37 (behaviour identical across all three).
  • PHP: reproduced on 8.3.31 and 8.4.22 (NTS), glibc.
  • SAPI: php-fpm. opcache on or off — no difference (see below).
  • Not dependent on agent or PHP version; only on whether the bound handler defines vs inherits report()/render().

Reproduction

  1. A Laravel 10 app (ships App\Exceptions\Handler extends ExceptionHandler with no override, bound as Illuminate\Contracts\Debug\ExceptionHandler).
  2. php-fpm with a single, non-recycling worker to force warm-worker reuse:
    pm = static
    pm.max_children = 1
    pm.max_requests = 0
    
  3. newrelic.loglevel = verbosedebug.
  4. A route that throws a reportable exception:
    Route::get('/boom', fn () => throw new \RuntimeException('boom'));
  5. Send 3 sequential requests to /boom (all hit the same worker).

Expected

All 3 requests record a RuntimeException TransactionError.

Actual

Only the first request records. In the agent log, recording error priority=... cls='RuntimeException' appears once (request 1) and never again for the warm worker.

Confirming it is the inherited method (backend-free, via the agent log)

Using newrelic.loglevel=verbosedebug and a single static worker, calling the bound handler's report() on a fresh RuntimeException three times on the same worker:

Bound ExceptionHandler warm-request result
Illuminate\Foundation\Exceptions\Handler (framework, directly) records on every request
empty subclass extends Handler (inherited report()) records only on the 1st request per worker
subclass that overrides report() (calls parent::report()) records on every request

The log signature: the wrap fires on the request where the wraprec is newly added to the process-level name map (adding custom wrapper for '<Class>::report'), and on reused requests (reusing custom wrapper for '<Class>::report') it re-fires for a defined method but does not re-fire for an inherited one.

opcache on vs off makes no difference (tested both) — this is not an opcache/SHM issue.

Diagnosis

nr_laravel_application_boot (agent/fw_laravel.c) resolves the bound ExceptionHandler and wraps report()/render() using the concrete resolved object's class entry:

nr_laravel_add_callback_method(Z_OBJCE_P(exception_handler),
                               NR_PSTR("report"),
                               nr_laravel_exception_report);

nr_laravel_add_callback_method then registers the wrap under that concrete class name:

class_name = nr_php_class_entry_name(ce);           // e.g. "App\Exceptions\Handler"
...
class_method = nr_formatf("%.*s::%.*s", ..., class_name, ..., method);
nr_php_wrap_user_function_before_after_clean(class_method, ...);

When the resolved handler is a subclass that only inherits report()/render(), the method's op_array is defined on the parent (Illuminate\Foundation\Exceptions\Handler) — ReflectionMethod::getDeclaringClass() confirms the parent. Registering the instrumentation under the subclass name is inconsistent with how the method is actually resolved/dispatched, and under OAPI the wrap ends up not being re-armed on warm workers (only the first-per-worker application takes effect). Wrapping the method under the class where it is defined (function->common.scope) makes the inherited case behave like the framework/overridden case, which re-arm correctly on every request.

Note: PR #877 improved exception-handler instrumentation for PHP 8.0+ but addressed the restore_exception_handler() / is_exception_handler flag path, not the inherited-subclass case; the concrete-class wrapping in nr_laravel_application_boot is unchanged on main.

Possible fix (direction, not validated against a build)

In nr_laravel_add_callback_method, after resolving the method, wrap it on its declaring class rather than the concrete (sub)class:

function = nr_php_find_class_method(ce, method);
if (NULL == function) { /* ...log... */ return; }

/* Wrap the method on the class where it is DEFINED, not the concrete
 * subclass that may merely inherit it (classic App\Exceptions\Handler
 * extends Handler). */
const zend_class_entry* def_ce =
    (NULL != function->common.scope) ? function->common.scope : ce;
class_name     = nr_php_class_entry_name(def_ce);
class_name_len = nr_php_class_entry_name_length(def_ce);

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions