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-php2 - 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-configMulti-tenant or multi-project setups construct isolated clients with createClient([...]) instead of the global singleton.
3 - Configurationlink
| Option | Default | What 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 |
enabled | true | Master switch. A disabled client returns '' from capture calls |
endpoint / sessionUrl | prod API / - | Must be valid http(s) URLs or ConfigException is thrown |
sampleRate | 1.0 | Range-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 |
batchSize | 50 | Validated 1 to 5000; the queue flushes automatically when it fills |
maxQueueSize | 1000 | Queue cap |
requestTimeout | 15000 ms | Per-request timeout |
retry | 3 attempts, 200 ms to 5 s, x2 | Exponential backoff |
beforeSend / httpClient / debug | - / - / false | Event 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,ipsurvive onuser; other keys are dropped before send. - Chained
getPrevious()exceptions serialise into acausesarray (rendered as the Caused-by chain on the issue page). - The
captureExceptionhint may carrytraceId/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
createClientclients 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'inconfig/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), setsmethod,url(the request path) androute(when the route has a name) tags, reads the inboundtraceparentheader so the request joins the caller's trace, and resets on termination. Customise identity withBugWatchContextMiddleware::resolveUserUsing(fn ($request) => [...])from a providerboot()(closures registered elsewhere breakconfig:cache); a throwing resolver never affects the request. - Long-lived runtimes: Octane's
RequestTerminatedplus queueJobProcessed/JobFailedflush and reset the scope automatically. ArtisanCommandFinishedonly flushes, so looping commands callresetScope()themselves. Plain PHP-FPM apps also flush at end of request via the frameworkterminating()hook, even without the middleware. RoadRunner has no automatic hook: use the middleware or manualflush()+resetScope()per request. Withext-swooleloaded, scope is stored per coroutine automatically. - Browser session mint:
BrowserSessionControlleris a ready-made route for the browser token flow; non-Laravel backends callmintBrowserSession(['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 todebug, 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, respectserror_reporting()and@, is recursion-guarded, and returns a handle withuninstall().
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 theConfigExceptionand writes[BugWatch] disabled: ...toerror_logunlessdebug => true. - Nothing arrives from a worker loop: call
flush()andresetScope()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
captureExceptioncalls 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.