newinstance / bugwatch-php
BugWatch SDK for capturing logs, errors, and runtime context from PHP applications.
Requires
- php: ^8.2
- psr/http-client: ^1
- psr/http-factory: ^1
- psr/http-message: ^1 || ^2
- psr/log: ^3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- guzzlehttp/guzzle: ^7.9
- monolog/monolog: ^3
- orchestra/testbench: ^11
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^11
Suggests
- ext-curl: Default zero-config HTTP transport
- laravel/framework: First-class Laravel integration (^11 || ^12 || ^13)
- monolog/monolog: Send Monolog records to BugWatch (^2 || ^3)
This package is not auto-updated.
Last update: 2026-06-22 23:47:16 UTC
README
One PHP SDK to capture logs, errors, exceptions, and runtime context from any PHP app — and send them to BugWatch.
One install. Every PHP framework and logger. A framework-independent core plus first-class, tested integrations for Monolog (2 & 3), Laravel, PSR-3, and native PHP error/exception/shutdown capture — all from the same package. Keep using your existing logger; BugWatch receives events transparently.
composer require newinstance/bugwatch-php
New to BugWatch? Create your account and a project at www.newinstance.cloud — that's where you generate the
projectKey("<keyId>:<secret>") used below.
Other languages? JavaScript / TypeScript apps use
@newinstance/bugwatch(npm). This package is PHP-only.
Quick start
use NewInstance\BugWatch\BugWatch; BugWatch::init([ 'projectKey' => getenv('BUGWATCH_KEY'), // "<keyId>:<secret>" — server-side only 'release' => getenv('APP_VERSION'), // optional ]); BugWatch::setUser(['id' => 'u_123', 'email' => 'alice@acme.com']); BugWatch::setTag('region', 'eu-west-1'); try { doRiskyThing(); } catch (\Throwable $e) { BugWatch::captureException($e); }
projectKey is all the configuration you need — the SDK already points at BugWatch's hosted ingest API. The
environment (development / staging / production) is bound to your key server-side, so you never send
it; use one key per environment.
Need isolated instances (multi-tenant, advanced)? Use createClient() instead of the global singleton:
use function NewInstance\BugWatch\createClient; $client = createClient(['projectKey' => getenv('BUGWATCH_KEY')]); $client->captureException($e);
Capturing events
BugWatch::captureException(\Throwable $e, array $hint = []): string; // returns the event id BugWatch::captureMessage(string $message, int|string $level = 'info'): string; BugWatch::captureLog([ 'level' => 'error', // name, Monolog int, or BugWatch numeric 'message' => 'payment gateway timeout', 'exception' => $e, // optional Throwable 'tags' => ['gateway' => 'paystack'], 'user' => ['id' => 'u_123'], 'fingerprint' => 'optional-grouping-key', ]): string;
$hint for captureException accepts level, tags, and user. Levels use the BugWatch numerics
trace 10 · debug 20 · info 30 · warn 40 · error 50 · fatal 60, but you can also pass a PSR-3 name
('error') or a Monolog integer (400) anywhere a level is expected — they're normalized for you.
Scope & context
Attach data once and it rides along with every subsequent event from the current client:
BugWatch::setUser(['id' => 'u_123', 'email' => 'alice@acme.com']); // allow-listed: id, email, username, ip BugWatch::setUser(null); // clear BugWatch::setTag('region', 'eu-west-1'); BugWatch::setTags(['plan' => 'pro', 'tenant' => 't_42']); BugWatch::setContext('payment', ['provider' => 'paystack']); // structured context BugWatch::setRelease('checkout@2.4.1'); BugWatch::setFingerprint('manual-grouping-key'); // string or string[] // A temporary, isolated scope — mutations are discarded when the callback returns: BugWatch::withScope(function ($scope) { $scope->tags['operation'] = 'checkout'; BugWatch::captureMessage('checkout step failed', 'error'); }); // lifecycle BugWatch::flush(); // send queued events now (returns bool: were all batches accepted?) BugWatch::close(); // final flush (on shutdown)
setUseronly keeps the safe identifiersid,username,ip. Other fields are dropped, and sensitive keys are redacted everywhere (see Privacy & redaction).
Native PSR-3 logger
The SDK ships a first-class Psr\Log\LoggerInterface — perfect for apps with no logging library:
$log = BugWatch::getLogger(); // implements Psr\Log\LoggerInterface $log->error('payment gateway timeout {gateway}', ['gateway' => 'paystack']); // {placeholders} interpolated $log->warning('low inventory', ['sku' => 'A-12']); $log->error('checkout failed', ['exception' => $e]); // a Throwable in context is captured as an exception
Hand it to any library or framework that accepts a PSR-3 logger and you're done.
Monolog (2 & 3) — your existing logger, unchanged
Most PHP apps (and Laravel, Symfony, Drupal, Magento, …) log through Monolog. Add BugWatch as one more handler and keep every existing handler:
use Monolog\Logger; use NewInstance\BugWatch\Integration\Monolog\Handler; $log = new Logger('payments'); $log->pushHandler(new Handler(BugWatch::client())); // optional 2nd arg: minimum level (default 'debug') $log->warning('cache miss', ['key' => 'u:1']); $log->error('charge failed', ['exception' => $e]); // exceptions in context become captured exceptions
Works with Monolog 2 (array records) and Monolog 3 (LogRecord) from the same class. The channel name
and any scalar context / extra values are forwarded as tags. The handler never throws into Monolog.
Framework-less PHP — native handlers (opt-in)
Capture uncaught exceptions, PHP errors, and fatal shutdowns (the failures a try/catch can never see):
use NewInstance\BugWatch\Handlers\ErrorHandler; BugWatch::init(['projectKey' => getenv('BUGWATCH_KEY')]); ErrorHandler::install(BugWatch::client()); // Fine-grained: ErrorHandler::install($client, ['errors' => true, 'exceptions' => true, 'shutdown' => true]);
The handlers chain any previously-registered handler, respect error_reporting() (including the @
operator), are recursion-guarded, and can be removed with ErrorHandler::install(...)->uninstall(). They are
opt-in by design — the SDK never installs global handlers behind your back.
Laravel (auto-discovered)
The package auto-registers via Laravel package discovery — no provider to add.
# .env (one key per environment) BUGWATCH_KEY="<keyId>:<secret>"
// config/logging.php — add the channel (or add 'bugwatch' to your 'stack') 'channels' => [ 'bugwatch' => ['driver' => 'bugwatch'], ],
That's it:
Log::error(...)routed to thebugwatchchannel is captured.- Uncaught exceptions are captured automatically (Laravel's own reporting is untouched).
- Queue jobs, Artisan commands, and Octane requests flush and reset per-request scope automatically.
Publish the config to customise it:
php artisan vendor:publish --tag=bugwatch-config
Optional extras:
// Request/route/user tags — add the middleware to your HTTP kernel or a route group: \NewInstance\BugWatch\Laravel\BugWatchContextMiddleware::class // Mint browser session tokens for your front-end (see "Front-end apps" below): use NewInstance\BugWatch\Laravel\Http\BrowserSessionController; Route::post('/bugwatch/session', BrowserSessionController::class);
Config keys (config/bugwatch.php): key, endpoint, release, enabled, sample_rate,
sensitive_fields, capture_exceptions (default true), level. Turn off automatic exception capture with
BUGWATCH_CAPTURE_EXCEPTIONS=false. Targets Laravel 11–13 (verified on 13).
Front-end / browser apps (secure)
Browser code is public — never put your projectKey in client-side code; it carries your secret. Instead,
let your PHP backend hand the browser a short-lived, ingest-only session token:
use function NewInstance\BugWatch\mintBrowserSession; // In a backend route — the only place the secret lives: $session = mintBrowserSession(['projectKey' => getenv('BUGWATCH_KEY')]); // ['token' => ..., 'expiresAt' => ...] header('Content-Type: application/json'); echo json_encode($session);
(Laravel users can just route BrowserSessionController as shown above.) Return the JSON to the browser and
use the @newinstance/bugwatch JS SDK there with a sessionUrl — the secret never reaches the client.
Long-running runtimes (Octane, queue workers, CLI loops)
In classic PHP-FPM each request is its own process, so scope can't leak. In persistent runtimes, reset per-request state at each boundary:
$client->flush(); // deliver what's queued $client->resetScope(); // clear user/tags/context/release/fingerprint for the next unit of work
The Laravel integration does this for you on queue-job, command, and Octane-request boundaries. The default
delivery model is buffer → flush on shutdown, and under PHP-FPM the SDK calls fastcgi_finish_request()
first so your response is returned to the user before events are sent — no added request latency.
Configuration
Pass to BugWatch::init([...]) / createClient([...]):
| Option | Default | Notes |
|---|---|---|
projectKey |
— (required) | "<keyId>:<secret>". Server-side only. Omit it (or set enabled => false) to no-op. |
endpoint |
https://api.newinstance.cloud |
Ingest base URL (internal override). |
release |
— | Release / version string. |
enabled |
true |
false no-ops all capture. |
debug |
false |
Log SDK-internal diagnostics to error_log. |
sampleRate |
1.0 |
0–1; fraction of events kept. |
sensitiveFields |
[] |
Extra keys to redact (merged with the built-in list). |
maxQueueSize |
1000 |
Bounded in-memory buffer; drops oldest on overflow. |
batchSize |
50 |
Events per ingest request (≤ 5000). |
flushInterval |
0 |
0 = flush on shutdown/boundaries only (the PHP-FPM default). |
requestTimeout |
15000 ms |
Per-request timeout. |
retry |
3 attempts · 200 ms → 5 s · ×2 + jitter | A RetryOptions instance. |
httpClient |
— | Inject a PSR-18 client to reuse (Guzzle, Symfony HttpClient, …); else cURL, else streams. |
beforeSend |
— | fn(array $event): ?array — return null to drop, or a modified event. |
Privacy & redaction
Redaction runs over every event before it's queued or serialized (and the server redacts again as
defense-in-depth). Default keys (case-insensitive) include: password, token, authorization, cookie,
secret, apikey, clientsecret, sessionid, ssn, creditcard, cvv, pin, bvn, nin, and more —
add your own with sensitiveFields. User context is limited to id, email, username, ip.
Requirements
- PHP 8.2+ (the supported floor; developed and verified on PHP 8.5 — a CI matrix across 8.2–8.5 is planned).
- Hard dependencies are minimal:
psr/logand the PSR-7/17/18 HTTP interface packages. - Optional (install only what you use):
monolog/monolog(^2 || ^3) — for the Monolog handler.laravel/framework(^11 || ^12 || ^13) — for the Laravel integration (auto-discovered).ext-curl— the default zero-config transport (a stream fallback is used if absent).
The core never pulls a framework in. Integrations activate only when their library is present.
Framework & logger support
First-class, tested integrations: Monolog (2 & 3) · Laravel (11/12/13) · native PSR-3 logger · native PHP error/exception/shutdown handlers · server-side browser-session mint.
Everything else works through the universal core today — any app that logs via Monolog or PSR-3
(Symfony, Slim, CakePHP, Drupal, Magento, …) is captured by attaching the Monolog handler or handing over the
PSR-3 logger; anywhere else, call captureException() / captureLog() directly. Dedicated one-line adapters
for more frameworks are on the roadmap.
Design guarantees
- Never crashes your app. Every capture / serialize / redact / transport path is wrapped; internal errors go to the diagnostics logger only — never thrown into your app, never re-captured (no recursion).
- Redacted by default — sensitive keys are scrubbed before events leave your process.
- Reliable, low-latency delivery — bounded queue, NDJSON batching, exponential-backoff retry with jitter,
flush on shutdown after
fastcgi_finish_request(). - Idempotent — each event carries a stable id, so transport retries never double-count server-side.
- Strictly typed — PHPStan at the max level with zero baseline; PSR-12.
Development
composer install composer test # phpunit composer stan # phpstan (level max) composer cs # php-cs-fixer (dry run); composer cs:fix to apply
License
MIT