Plain PHP
BugWatch for plain PHP and any frameworklink
The BugWatch PHP SDK has no framework dependency. The core works the same in a raw front controller, Slim, Symfony, a CLI script, or a custom worker loop. Integrations activate only when their library is present, so nothing is pulled in that you do not use.
Installlink
composer require newinstance/bugwatch-phpRequires PHP 8.2 or higher. ext-curl is optional and used for the default transport, with a PHP stream fallback when it is absent.
Store the key in the environment, never in code:
BUGWATCH_KEY="<keyId>:<secret>"
APP_VERSION="1.3.2"Get the key from www.newinstance.cloud under your project's Settings then API Keys, with the ingest:write scope. The key format is <keyId>:<secret> and is server-side only.
Initialiselink
Call BugWatch::init() once, as early as possible in your bootstrap:
use NewInstance\BugWatch\BugWatch;
BugWatch::init([
'projectKey' => getenv('BUGWATCH_KEY'),
'release' => getenv('APP_VERSION'), // optional
]);After init() the static facade (BugWatch::captureException(), BugWatch::setTag(), and so on) works anywhere in the process. If you need more than one configuration, for example a multi-tenant app sending to different projects, use createClient() instead and pass the instance around:
use function NewInstance\BugWatch\createClient;
$client = createClient(['projectKey' => getenv('BUGWATCH_KEY')]);
$client->captureException($e);createClient() returns a Client with the same API surface as the facade. One difference: only BugWatch::init() registers the automatic shutdown flush, so a createClient() instance must call flush() (or close()) itself before the process exits.
Native error handlerslink
try/catch cannot see uncaught exceptions, PHP warnings, or fatal shutdowns. ErrorHandler::install() registers handlers for all three. It is opt-in by design: the SDK never installs global handlers on its own.
use NewInstance\BugWatch\Handlers\ErrorHandler;
BugWatch::init(['projectKey' => getenv('BUGWATCH_KEY')]);
ErrorHandler::install(BugWatch::client());Each of the three handlers can be toggled independently. All three default to true:
$handler = ErrorHandler::install(BugWatch::client(), [
'exceptions' => true, // set_exception_handler
'errors' => false, // set_error_handler
'shutdown' => true, // register_shutdown_function
]);What each one does:
exceptions: captures the uncaughtThrowableat levelfatalwith ahandled=falsetag, flushes immediately, then calls the previously registered exception handler if there was one. Your existing handler still runs.errors: capturesE_WARNING,E_NOTICE,E_DEPRECATEDand friends as log events, mapped towarning,notice,info, orerrorby errno, tagged withfile,line,errno, andhandled=false. It returnsfalse, so PHP's normal error handling continues to run as well.shutdown: on shutdown, inspectserror_get_last()and captures only if the last error isE_ERROR,E_PARSE,E_CORE_ERROR, orE_COMPILE_ERROR. Anything else is ignored, so a clean request produces nothing. Fatal captures are flushed before the process dies.
The error handler respects error_reporting(). A diagnostic suppressed with the @ operator, or one below your error_reporting threshold, is skipped entirely. All handlers are recursion guarded: if capture itself raises, the exception is swallowed rather than being allowed to escape into your application. ErrorHandler::install() is process-wide and bound to the first client it was installed with; a second install() with a different client keeps the first.
Removing the handlers:
$handler->uninstall();uninstall() restores the previous exception and error handlers. PHP cannot unregister a shutdown function, so the shutdown handler stays registered but stays a no-op unless error_get_last() reports a fatal type. Calling install() again after uninstall() will not register a duplicate shutdown function.
Capturing manuallylink
$eventId = BugWatch::captureException($e);
$eventId = BugWatch::captureException($e, [
'level' => 'fatal',
'tags' => ['component' => 'checkout'],
'user' => ['id' => 'u_123'],
]);
BugWatch::captureMessage('Payment gateway timed out', 'warn');
BugWatch::captureLog([
'level' => 'error',
'message' => 'Order creation failed',
'exception' => $e,
'tags' => ['order_id' => '1234', 'gateway' => 'paystack'],
'user' => ['id' => 'u_123'],
'fingerprint' => 'order-creation-failure',
]);Per-request isolation outside Laravellink
Under PHP-FPM, mod_php, or a one-shot CLI script, every request is its own OS process, so scope is process private and nothing can bleed between requests. You do not have to do anything.
When the swoole extension is loaded, the SDK stores each coroutine's scope in Swoole\Coroutine::getContext(), so concurrent requests inside one worker never share a user or tags. Also automatic.
The case that needs attention is a persistent, sequential runtime: RoadRunner, a hand-rolled accept loop, or a long-running consumer. One process serves many units of work in order, so state set for one survives into the next unless you clear it.
// Wrong on a persistent worker: request B inherits request A's user
while ($request = $server->accept()) {
BugWatch::setUser(['id' => $request->userId]);
handle($request);
}
// Correct: flush and reset at the boundary
while ($request = $server->accept()) {
BugWatch::setUser(['id' => $request->userId]);
handle($request);
BugWatch::client()->flush();
BugWatch::client()->resetScope();
}resetScope() wipes user, tags, context, release, fingerprint, and the trace context. Two alternatives avoid the shared scope altogether:
// withScope: state set inside is discarded on return, outer scope untouched
BugWatch::withScope(function ($scope) use ($tenantId) {
$scope->tags['tenant'] = $tenantId;
$scope->user = ['id' => 'u_999'];
BugWatch::captureMessage('Scoped event', 'warn');
});
// Explicit per-capture context: nothing shared at all
BugWatch::captureException($e, [
'user' => ['id' => $userId],
'tags' => ['route' => $routeName, 'tenant' => $tenantId],
]);Explicit per-capture context is the safest default for any custom worker loop, because every event is self-contained.
Joining a tracelink
Outside Laravel there is no middleware reading the inbound traceparent header, so parse it yourself at the top of the request:
use NewInstance\BugWatch\TraceContext;
$parsed = TraceContext::parseTraceparent($_SERVER['HTTP_TRACEPARENT'] ?? null);
if ($parsed !== null) {
BugWatch::setTraceContext($parsed['traceId'], $parsed['spanId']);
}parseTraceparent() returns ['traceId' => ..., 'spanId' => ...] or null. Validation is strict: 32 and 16 hex characters (uppercase input is normalised to lowercase), all-zero IDs rejected. Invalid input yields null rather than an exception, so the guard above is all you need. BugWatch::setTraceContext(null, null) clears the context, and this should happen alongside resetScope() on a persistent runtime.
On outbound calls, propagate the context so the next service joins your trace:
$headers = BugWatch::traceHeaders(); // ['traceparent' => '00-<traceId>-<spanId>-01'] or []Inside a withSpan() callback, prefer $span->traceparent() so the downstream service links to that exact span rather than the scope's current one.
Flushing at the end of a requestlink
BugWatch::init() registers a shutdown function that flushes the queue, so a normal request needs no explicit call. Under PHP-FPM that shutdown function calls fastcgi_finish_request() first, which returns the HTTP response to the client before the events are transmitted, so ingest adds no latency to the response.
Call flush() yourself when the process will not exit soon: at the end of each iteration of a worker loop, before a long sleep, or right before a hard exit. It returns bool. The queue is bounded by maxQueueSize (default 1000) and drops oldest first on overflow, so flushing more often keeps worker memory flat.
Complete example: front controllerlink
<?php
// public/index.php
require __DIR__ . '/../vendor/autoload.php';
use NewInstance\BugWatch\BugWatch;
use NewInstance\BugWatch\Handlers\ErrorHandler;
use NewInstance\BugWatch\TraceContext;
BugWatch::init([
'projectKey' => getenv('BUGWATCH_KEY'),
'release' => getenv('APP_VERSION'),
'serviceName' => 'storefront',
]);
ErrorHandler::install(BugWatch::client());
$parsed = TraceContext::parseTraceparent($_SERVER['HTTP_TRACEPARENT'] ?? null);
if ($parsed !== null) {
BugWatch::setTraceContext($parsed['traceId'], $parsed['spanId']);
}
BugWatch::setTag('method', $_SERVER['REQUEST_METHOD'] ?? 'CLI');
BugWatch::setTag('path', parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/');
if ($userId = currentUserId()) {
BugWatch::setUser(['id' => (string) $userId]);
}
try {
$response = router()->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
http_response_code($response->status);
echo $response->body;
} catch (\Throwable $e) {
BugWatch::captureException($e, ['tags' => ['component' => 'router']]);
http_response_code(500);
echo json_encode(['error' => 'Internal server error']);
}
// The shutdown hook flushes after fastcgi_finish_request(); no explicit flush needed here.Troubleshootinglink
Events do not appear in the dashboard. Set 'debug' => true in init() and check error_log for [BugWatch] entries. Then call BugWatch::flush() explicitly and watch for transport errors. Confirm BUGWATCH_KEY is a valid <keyId>:<secret> string, that enabled is not false, and that sampleRate is not 0.0.
BugWatch: "projectKey" (or "sessionUrl") is required. means init() ran with enabled true but no key. Load your environment before init().
Uncaught exceptions are not captured. Check that ErrorHandler::install() runs after BugWatch::init() and that nothing later in your bootstrap calls set_exception_handler() without chaining. The SDK chains the handler that existed when it installed, but a framework installing its own afterwards can displace it.
Warnings are not captured. The error handler honours error_reporting() and the @ operator by design. Raise error_reporting or remove the suppression on the call site you care about.
Fatal errors are missing. Only E_ERROR, E_PARSE, E_CORE_ERROR, and E_COMPILE_ERROR are captured from the shutdown path. A memory exhaustion fatal may also leave too little memory for the flush to complete; lower memory_limit headroom pressure or reduce batchSize.
Worker memory keeps growing. maxQueueSize bounds the buffer at 1000 events by default and drops oldest first. Call flush() at each iteration boundary rather than raising the limit.
Events land on the wrong trace. On a persistent runtime, setTraceContext() persists until it is changed or cleared. Call resetScope() at each boundary or pass traceId and spanId in the capture hint.