PHP

BugWatch for PHP: exceptions, messages and structured logs from any PHP 8.2+ application, with deep Laravel integration and adapters for Monolog, PSR-3 and native error handling.

Requirementslink

  • PHP 8.2+; hard deps are psr/log ^3, psr/http-client ^1, psr/http-factory ^1, psr/http-message ^1|^2
  • Default transport is cURL with a PHP-streams fallback; a PSR-18 client is used only if you inject one via httpClient
  • Laravel 11 to 13 via the bundled auto-discovered service provider; Monolog 2/3 optional

1 - Installlink

composer require newinstance/bugwatch-php

2 - Initialiselink

Plain PHP:

use NewInstance\BugWatch\BugWatch;

BugWatch::init([
    'projectKey' => getenv('BUGWATCH_KEY'),
    'release'    => getenv('APP_VERSION'),
]);

Laravel: set the env keys and you are done; publish the config to tune more:

BUGWATCH_KEY=sk_live_KEYID:secret
BUGWATCH_RELEASE=2.4.1
BUGWATCH_ENABLED=true
BUGWATCH_CAPTURE_EXCEPTIONS=true

php artisan vendor:publish --tag=bugwatch-config

Multi-tenant or multi-project setups construct isolated clients with createClient([...]) instead of the global singleton.

3 - Configurationlink

OptionDefaultWhat it does
projectKey-Server credential (KEYID:secret). Environment is bound to the key server-side: one key per environment, no environment option in this SDK
release-Version label on every event
serviceName-Logical service name stamped on spans (OTel service.name); powers the service map. Tracing only, not on events
enabledtrueMaster switch. A disabled client returns '' from capture calls
endpoint / sessionUrlprod API / -Must be valid http(s) URLs or ConfigException is thrown
sampleRate1.0Range-validated 0.0 to 1.0; a sampled-out event still returns an id (freshly generated unless the input carried its own eventId)
sensitiveFields[]Your keys merge with the built-in redaction list
batchSize50Validated 1 to 5000; the queue flushes automatically when it fills
maxQueueSize1000Queue cap
requestTimeout15000 msPer-request timeout
retry3 attempts, 200 ms to 5 s, x2Exponential backoff
beforeSend / httpClient / debug- / - / falseEvent filter, custom PSR-18 transport, diagnostics

Flushing happens when the queue reaches batchSize, on explicit flush(), and at shutdown. flush() also drains buffered spans. Under PHP-FPM the shutdown flush runs after fastcgi_finish_request(), so delivery adds no response latency.

Laravel maps key, endpoint, release, service name (BUGWATCH_SERVICE_NAME), enabled, sample rate and sensitive fields from config/bugwatch.php; the remaining options require createClient or plain init().

4 - Capture APIlink

$id = BugWatch::captureException($e, ['tags' => ['route' => $routeName], 'traceId' => $traceId, 'spanId' => $spanId]);
BugWatch::captureMessage('Sync finished', 'info');
BugWatch::captureLog(['level' => 'warn', 'message' => 'Slow query', 'tags' => ['db' => 'orders']]);
BugWatch::setUser(['id' => 'u_123', 'email' => 'ada@example.com']);
BugWatch::setTags(['tenant' => 't_42']);
BugWatch::setContext('payment', ['provider' => 'paystack']);
BugWatch::setRelease('checkout@2.4.1');
BugWatch::setFingerprint(['checkout', 'GATEWAY_TIMEOUT']);
BugWatch::withScope(function ($scope) use ($e) { $scope->setTag('job', 'sync'); BugWatch::captureException($e); });
BugWatch::resetScope();
BugWatch::flush();
BugWatch::close();
  • Levels accept BugWatch numerics, PSR-3 names and Monolog ints (notice maps to 30; critical, alert and emergency map to 60).
  • Only id, email, username, ip survive on user; other keys are dropped before send.
  • Chained getPrevious() exceptions serialise into a causes array (rendered as the Caused-by chain on the issue page).
  • The captureException hint may carry traceId/spanId; a valid hint trace wins over the scope's trace context wholesale (the scope span is never mixed into a hinted trace).
  • Capturing the same Throwable instance twice returns the first event id (WeakMap dedupe, per client instance: two createClient clients each report it once).
  • client() exposes the underlying client; diagnostics() returns delivery counters.

5 - Laravel integrationlink

Deeper pages in this section's sidebar: Laravel (full guide incl. Octane, queues, tracing), Plain PHP (front controllers, native error handlers, any framework), and Monolog & PSR-3 (log forwarding). The sections below are the quick summary.

  • Exceptions: reported automatically; disable with BUGWATCH_CAPTURE_EXCEPTIONS=false.
  • Log::* to BugWatch: add a channel with 'driver' => 'bugwatch' in config/logging.php (standalone or inside a stack); without it, Laravel logs never reach BugWatch.
  • Per-request users and trace joining: register BugWatchContextMiddleware; it scopes the authenticated user (id only by default), sets method, url (the request path) and route (when the route has a name) tags, reads the inbound traceparent header so the request joins the caller's trace, and resets on termination. Customise identity with BugWatchContextMiddleware::resolveUserUsing(fn ($request) => [...]) from a provider boot() (closures registered elsewhere break config:cache); a throwing resolver never affects the request.
  • Long-lived runtimes: Octane's RequestTerminated plus queue JobProcessed/JobFailed flush and reset the scope automatically. Artisan CommandFinished only flushes, so looping commands call resetScope() themselves. Plain PHP-FPM apps also flush at end of request via the framework terminating() hook, even without the middleware. RoadRunner has no automatic hook: use the middleware or manual flush() + resetScope() per request. With ext-swoole loaded, scope is stored per coroutine automatically.
  • Browser session mint: BrowserSessionController is a ready-made route for the browser token flow; non-Laravel backends call mintBrowserSession(['projectKey' => ...]) which returns ['token', 'expiresAt'] (see Browser Ingest).

6 - Distributed tracinglink

The PHP SDK creates spans and joins W3C traces, so a PHP service appears on the same waterfall as the JS services calling it (or that it calls). Set serviceName so spans land on the right service-map node.

Joining a trace. In Laravel the context middleware reads traceparent automatically. Anywhere else:

use NewInstance\BugWatch\TraceContext;

$parsed = TraceContext::parseTraceparent($_SERVER['HTTP_TRACEPARENT'] ?? null);
if ($parsed !== null) {
    BugWatch::setTraceContext($parsed['traceId'], $parsed['spanId']);
}

IDs are validated strictly (32 and 16 lowercase hex, all-zero rejected). getTraceContext() reads the active context; setTraceContext(null, null) clears it.

Spans. withSpan(name, fn, opts) times the callback, links captures and logs inside it to the span, records a thrown exception on the span (type, message, stacktrace, error status), restores the scope and rethrows. startSpan gives manual control with setAttr, recordException, end() (idempotent) and $span->traceparent(). Options: kind (1 internal, 2 server, 3 client, 4 producer, 5 consumer), attrs, traceId, parentSpanId, links (a queue consumer links to the producer's ['traceId' => ..., 'spanId' => ...] and the service map draws the async edge). code.filepath/code.lineno/code.function attributes surface the source location on the span detail.

$result = BugWatch::withSpan('db.query load-cart', function ($span) use ($cartId) {
    $span->setAttr('db.system', 'mysql');
    return loadCart($cartId);
}, ['kind' => 3]);

Propagating out. BugWatch::traceHeaders() returns ['traceparent' => ...] (or []) for outbound HTTP; inside withSpan prefer $span->traceparent() so the child links to that exact span. TraceContext::buildTraceparent($traceId, $spanId) builds the header from raw ids.

Limits and delivery. 50 attributes (200-char keys and values, scalars only), 20 events, 10 links, 200-char names, 8000-char exception stacktraces, 200-span buffer with oldest dropped. Spans post as OTLP JSON to POST /v1/traces with the project key and flush with flush() and the Laravel lifecycle hooks. Caveats: span export uses cURL directly (httpClient, retry, beforeSend, sampleRate and redaction apply to events only, so keep sensitive data out of span attributes), and a sessionUrl-only client captures events but exports no spans.

7 - Logging adapterslink

$log->pushHandler(new \NewInstance\BugWatch\Integration\Monolog\Handler(BugWatch::client(), 'warning'));
  • One Monolog handler class serves Monolog 2 arrays and Monolog 3 LogRecord; level defaults to debug, the third argument is $bubble; channel plus scalar context and extra become tags; it never throws into Monolog.
  • getLogger() returns a PSR-3 logger with {placeholder} interpolation; a Throwable in context becomes a captured exception.
  • ErrorHandler::install($client, ['exceptions' => true, 'errors' => true, 'shutdown' => true]) chains existing handlers, respects error_reporting() and @, is recursion-guarded, and returns a handle with uninstall().

Testinglink

use NewInstance\BugWatch\Client;
use NewInstance\BugWatch\Config;
use NewInstance\BugWatch\Testing\InMemoryTransport;

$transport = new InMemoryTransport();
$client = new Client(Config::fromArray(['projectKey' => 'k:s']), $transport);

Assert on $transport->events; set $transport->result = false to simulate delivery failure. The transport receives events only: spans bypass it. To intercept spans, pass a sender callable as the second constructor argument of Tracing\SpanExporter (it receives the OTLP JSON body, headers and URL and returns ['status' => 200]).

Troubleshootinglink

  • Silent with a missing key: Laravel self-disables the client; plain init() swallows the ConfigException and writes [BugWatch] disabled: ... to error_log unless debug => true.
  • Nothing arrives from a worker loop: call flush() and resetScope() at each unit-of-work boundary, otherwise the previous job's identity leaks into the next and events sit queued.
  • Laravel reports twice: remove manual captureException calls from your own handler once automatic capture is on.

Wire calls this SDK makes: POST /api/v1/bugwatch/ingest with Content-Type: application/x-ndjson and x-api-key, POST /api/v1/bugwatch/browser-session for the browser flow, and POST /v1/traces (OTLP JSON) for spans.