forge-ops/tracker

ForgeOps error tracking client: captures unhandled exceptions (Laravel/Symfony integration, plus explicit capture anywhere else) and delivers them to a ForgeOps instance over HTTP.

Maintainers

Package info

github.com/Luke-Popwell/forge-ops-tracker-php

pkg:composer/forge-ops/tracker

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

dev-main 2026-08-28 10:51 UTC

This package is auto-updated.

Last update: 2026-08-28 12:08:53 UTC


README

PHP error reporting client for a private, self-hosted ForgeOps tracker instance. Requires PHP 8.1+. A from-scratch port of gems/forge_ops_tracker (the Rails client) -- see that gem's README for the shared design rationale; this document only covers what's PHP-specific.

Installation

Not yet published to Packagist -- install directly from this path (or a local checkout, once split into its own repo):

composer config repositories.forge-ops-tracker path path/to/forge_ops/sdks/php
composer require forge-ops/tracker:@dev

Configuration

Set a DSN (from a project's settings page in ForgeOps), either via the FORGE_OPS_DSN environment variable or explicitly:

use ForgeOps\Tracker\ForgeOpsTracker;

ForgeOpsTracker::init(
    dsn: 'https://<api_key>@your-forgeops-host/api/v1/events', // or leave unset to read FORGE_OPS_DSN
    release: '...',
    environment: 'production',
);

Laravel

// config/app.php
'providers' => [
    ...,
    ForgeOps\Tracker\Integrations\Laravel\ForgeOpsTrackerServiceProvider::class,
],

Call ForgeOpsTracker::init(...) somewhere early -- a service provider's register(), or bootstrap/app.php.

Symfony

Register ForgeOps\Tracker\Integrations\Symfony\ForgeOpsTrackerExceptionListener as a service (autowired/autoconfigured automatically under most skeleton configs, since it implements EventSubscriberInterface). Call ForgeOpsTracker::init(...) somewhere early -- e.g. your AppKernel/Kernel::boot(), or a compiler pass.

What gets reported automatically, and what doesn't

An exception that crashes a request needs no further wiring at all. Laravel's reportable() hook and Symfony's kernel.exception listener both fire for anything that propagates uncaught out of a controller, then let the framework handle it exactly as if this client weren't installed.

An exception your own code catches and handles is different -- neither integration ever sees it, since it never propagates far enough to reach either hook:

try {
    chargeCard($order);
} catch (CardException $e) {
    $logger->warning("card declined: {$e->getMessage()}");
    // ForgeOps never sees this -- caught locally, never reaches the
    // reportable()/kernel.exception hook at all.
}

There's no Laravel/Symfony-wide equivalent to Rails' Rails.error.handle here -- report it explicitly instead, right at the catch site:

catch (CardException $e) {
    ForgeOpsTracker::captureException($e, ['order_id' => $order->id]);
    $logger->warning("card declined: {$e->getMessage()}");
}

Outside a web request (scripts, Artisan/console commands)

ForgeOpsTracker::init() also installs a set_exception_handler() wrapper by default (installExceptionHandler: false to opt out), which reports anything that crashes the script outright with no wiring needed -- the same "unhandled needs no wiring" case the Laravel/Symfony integrations cover for web requests. It still calls whatever handler was already installed afterward, so it never changes program behavior. This does not catch a web request's unhandled exception under a real app server -- Laravel/Symfony catch that themselves, long before it would ever reach here.

Delivery: not a background thread

Unlike the Ruby/.NET/Python clients, DeliveryQueue here is not a background thread + bounded queue. A typical PHP request (PHP-FPM or similar) is single-threaded and shared-nothing between requests, so there's no persistent worker to start in the first place. Instead, delivery is deferred via register_shutdown_function() + fastcgi_finish_request() (when available, i.e. under PHP-FPM specifically): the shutdown callback runs after the script would otherwise have ended, and fastcgi_finish_request() flushes the response to the visiting user first -- so the actual HTTP call(s) to ForgeOps happen after their connection has already been served, adding no latency they'd notice. This is the standard, idiomatic substitute real PHP error trackers use for the same problem. Outside FPM (plain CLI, where fastcgi_finish_request() doesn't exist at all), delivery still happens in the shutdown function, just without that "already sent" guarantee.

DeliveryQueue::flush() is public for the same reason: a long-running CLI worker that processes many units of work in one process can call it explicitly after each one, rather than only ever getting a real flush at final process exit.

Every failure mode -- network errors, timeouts, a full queue, a malformed DSN -- is caught and dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.

in_app backtrace frames

Like the Ruby gem (and unlike the .NET SDK, where a compiled assembly's file path never matches its original source location), PHP runs interpreted directly from real .php files on disk, so file-path matching against Configuration::$appRoot works the same way it does in the Ruby gem's Rails.root comparison. Defaults to the current working directory; set it explicitly if that doesn't match your app's actual layout. vendor/ frames are never marked in_app, regardless of appRoot.

Note also that PHP's own backtrace format (Throwable::getTrace()) is call-site-shifted: each frame's file/line is where that frame's function was called from, not where the function itself executes. EventBuilder re-pairs this into the same file/line/method-per-frame shape the other clients use (verified directly against a real nested throw, not assumed) -- see its source comment for the full explanation. A related quirk: PHP captures an exception's backtrace at construction time, not at throw time, so (unlike Ruby/.NET/Python) there's no "empty backtrace" case for an exception that's constructed but never thrown.

PII scrubbing

Same behavior as the Ruby gem: the message, backtrace, and any context/tags you attach are scanned for likely personal data -- email addresses, formatted SSNs/credit cards, known API key/token formats, and anything under a suspiciously-named key (password, api_key, ssn, and similar) -- and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival regardless, so this is a second, earlier layer, not the only one.

To disable it:

ForgeOpsTracker::init(dsn: '...', scrubPii: false);

Running the tests

cd sdks/php
composer install
vendor/bin/phpunit
vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes