Laravel
BugWatch for Laravellink
The newinstance/bugwatch-php SDK ships a Laravel integration that is registered through package
discovery. Install it, set three env vars, and unhandled exceptions are already reaching BugWatch.
Everything else on this page is opt in.
Requirements: PHP 8.2+, laravel/framework ^11|^12|^13.
Installlink
composer require newinstance/bugwatch-phpNewInstance\BugWatch\Laravel\BugWatchServiceProvider is auto discovered. Do not add it to
config/app.php yourself.
The env vars (only the key is required)link
BUGWATCH_KEY="<keyId>:<secret>" # from BugWatch project Settings -> API Keys, scope ingest:write
BUGWATCH_RELEASE="1.3.2" # version string or git SHA, used for grouping and deploy correlation
BUGWATCH_SERVICE_NAME="checkout-api" # logical service name on spans, powers the service mapOptional knobs, all read by config/bugwatch.php:
BUGWATCH_ENABLED=true
BUGWATCH_SAMPLE_RATE=1.0
BUGWATCH_CAPTURE_EXCEPTIONS=true # false disables the exception handler decorator
BUGWATCH_LEVEL="debug" # minimum level forwarded through the bugwatch log channel
BUGWATCH_ENDPOINT=https://api.newinstance.cloud # only for self hosted overridesIf BUGWATCH_KEY is missing the provider builds a disabled client instead of throwing. Your app boots
normally and sends nothing.
What auto discovery gives you with zero codelink
- Unhandled exceptions. The provider decorates Laravel's
ExceptionHandler, so anything Laravel would report (it honours the inner handler'sshouldReport, meaning yourdontReportlist is respected) is captured withlevel=errorand taghandled=true. Laravel's own reporting still runs afterwards. - End of request flush through the framework
terminating()hook, so plain PHP-FPM apps deliver events without any middleware. - Queue jobs:
JobProcessedandJobFailedtriggerflush()plusresetScope(). - Artisan:
CommandFinishedtriggersflush(). - Octane:
RequestTerminatedtriggersflush()plusresetScope(). - A
bugwatchMonolog log channel driver, ready to be wired intoconfig/logging.php.
The bugwatch log channellink
Log::error(...) is not forwarded until you add the channel. The driver name is bugwatch.
// config/logging.php
'channels' => [
// standalone
'bugwatch' => [
'driver' => 'bugwatch',
// 'level' => 'warning', // optional, overrides BUGWATCH_LEVEL for this channel
],
// or inside your default stack
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'bugwatch'],
],
],Records at or above the level become BugWatch events. Passing an exception in the context array
(['exception' => $e]) captures it as an exception, not a plain message.
Publishing the configlink
php artisan vendor:publish --tag=bugwatch-configThis copies config/bugwatch.php into your app. Publish it when you need sensitive_fields, which has
no env var and is the one setting you normally have to edit by hand.
// config/bugwatch.php
'sensitive_fields' => ['otp', 'national_id', 'card_number'],The context middlewarelink
BugWatchContextMiddleware is optional but recommended. Register it globally or on a group.
// bootstrap/app.php (Laravel 11+ style)
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\NewInstance\BugWatch\Laravel\BugWatchContextMiddleware::class);
})
// or app/Http/Kernel.php, in $middleware or a group such as 'web'
\NewInstance\BugWatch\Laravel\BugWatchContextMiddleware::class,What it does per request:
- Sets tags
method(HTTP verb),url(request path, leading slash normalised) androute(the route name, only when the route is named). - Sets the user. By default only the default guard's identifier, as
['id' => '<auth identifier>']. - Reads the inbound
traceparentheader and callssetTraceContext(), so captures and spans in this request join the upstream caller's trace (another service, or the BugWatch JS SDK'swrapFetch). - On
terminate(), callsflush()thenresetScope(). Events go out after the response, and the scope is clean for the next request on a long lived worker.
Every step runs inside a try/catch. Context enrichment can never break a request.
To attach a richer user, register a resolver once from a provider's boot():
// app/Providers/AppServiceProvider.php
use Illuminate\Http\Request;
use NewInstance\BugWatch\Laravel\BugWatchContextMiddleware;
public function boot(): void
{
BugWatchContextMiddleware::resolveUserUsing(function (Request $request): ?array {
$user = $request->user();
return $user ? ['id' => (string) $user->id, 'email' => $user->email, 'tenant' => $user->tenant_id] : null;
});
}Return null for anonymous requests. Registering in boot() and not in config keeps
php artisan config:cache working, since closures cannot be cached. A throwing resolver is swallowed and
treated as anonymous. forgetUserResolver() clears it again, useful in tests.
Long lived runtimeslink
| Runtime | Handled by | You do |
|---|---|---|
| PHP-FPM, one shot CLI | Process isolation plus terminating() flush | Nothing |
| Octane | RequestTerminated flush + reset, and the middleware's terminate() | Nothing extra, both together is harmless |
| Queue workers | JobProcessed and JobFailed flush + reset | Nothing |
| Artisan commands | CommandFinished flush only | Looping commands call resetScope() per iteration |
| RoadRunner | Not event driven | Call flush() + resetScope() at each request boundary |
| Swoole / OpenSwoole | Per coroutine scope via Swoole\Coroutine::getContext() | Nothing, concurrent requests never share scope |
CommandFinished deliberately flushes without resetting, because a command's scope is usually still
wanted while the command runs. A command with its own consumer loop must reset itself:
// app/Console/Commands/ConsumeOrders.php
$client = app(\NewInstance\BugWatch\Client::class);
while ($message = $stream->next()) {
$client->setTag('order_id', $message->orderId);
$this->process($message);
$client->flush();
$client->resetScope(); // without this, order_id leaks into the next iteration
}Tracing in Laravellink
Spans are attributed to BUGWATCH_SERVICE_NAME, so give each app, worker fleet and API a distinct value.
The middleware already joins the inbound trace, so a span created during a request continues the caller's
trace rather than starting a new one.
Resolve the container client and wrap the unit of work:
use NewInstance\BugWatch\Client;
use NewInstance\BugWatch\Tracing\Span;
$client = app(Client::class);
$total = $client->withSpan('cart.total', function (Span $span) use ($cart) {
$span->setAttr('cart.id', $cart->id);
return $cart->recalculate();
}, ['kind' => 1]); // OTel kind: 1 internal, 2 server, 3 client, 4 producer, 5 consumerwithSpan times the callback, links captures and logs made inside it to the span, records a thrown
exception on the span and rethrows it.
For outbound HTTP, propagate the header so the next service joins the trace:
$client->withSpan('http POST billing/charge', function (Span $span) use ($payload) {
return Http::withHeaders(['traceparent' => $span->traceparent()])
->post('https://billing.internal.example.com/charge', $payload);
}, ['kind' => 3]);Outside a span, NewInstance\BugWatch\BugWatch::traceHeaders() returns
['traceparent' => '...'] from the active scope, or [] when there is no trace. Note that the static
facade uses its own client, so in Laravel prefer $span->traceparent(), or build the header from the
container client with TraceContext::buildTraceparent(...) and $client->getTraceContext().
Span export caveats: spans need a projectKey, they bypass sampleRate, beforeSend and redaction, and
the buffer holds 200 spans (auto-flushing whenever it reaches batchSize, and on every flush()). Keep sensitive data out of span attributes.
Browser session mintinglink
Your project key must never reach the browser. Register the bundled controller and point the JS SDK at it.
// routes/web.php
use NewInstance\BugWatch\Laravel\Http\BrowserSessionController;
Route::post('/bugwatch/session', BrowserSessionController::class)->middleware('auth');It reads bugwatch.key and bugwatch.endpoint from config, mints a short lived token, and returns
{ token, expiresAt }, or a 502 with {"error": "failed to mint BugWatch session"} on failure. Always
put it behind auth. In the browser, init @newinstance/bugwatch with sessionUrl: '/bugwatch/session'.
Complete examplelink
# .env
BUGWATCH_KEY="sk_live_abc123:secret"
BUGWATCH_RELEASE="1.3.2"
BUGWATCH_SERVICE_NAME="checkout-api"
LOG_CHANNEL=stack// config/logging.php
'channels' => [
'stack' => ['driver' => 'stack', 'channels' => ['daily', 'bugwatch']],
'daily' => ['driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'days' => 14],
'bugwatch' => ['driver' => 'bugwatch'],
],// bootstrap/app.php
->withMiddleware(function (Illuminate\Foundation\Configuration\Middleware $middleware) {
$middleware->web(append: [\NewInstance\BugWatch\Laravel\BugWatchContextMiddleware::class]);
})// app/Providers/AppServiceProvider.php - boot()
use Illuminate\Http\Request;
use NewInstance\BugWatch\Laravel\BugWatchContextMiddleware;
BugWatchContextMiddleware::resolveUserUsing(fn (Request $r): ?array => $r->user()
? ['id' => (string) $r->user()->id, 'email' => $r->user()->email]
: null);// app/Jobs/ProcessPayment.php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
use NewInstance\BugWatch\Client;
use NewInstance\BugWatch\Tracing\Span;
final class ProcessPayment implements ShouldQueue
{
public function __construct(private readonly int $orderId, private readonly string $userId)
{
}
public function handle(Client $bugwatch): void
{
$bugwatch->setTag('order_id', (string) $this->orderId);
$bugwatch->setUser(['id' => $this->userId]);
try {
$bugwatch->withSpan('payment.charge', function (Span $span) {
$span->setAttr('payment.gateway', 'paystack');
return app(\App\Services\Gateway::class)->charge($this->orderId);
}, ['kind' => 3]);
} catch (\Throwable $e) {
Log::channel('bugwatch')->error('Payment job failed', ['exception' => $e]);
throw $e;
}
// JobProcessed / JobFailed flush and reset the scope for you
}
}Troubleshootinglink
Nothing arrives and nothing errors. The provider disables the client when bugwatch.key is empty, by
design. Check BUGWATCH_KEY is a valid <keyId>:<secret> string, BUGWATCH_ENABLED is not false,
BUGWATCH_SAMPLE_RATE is not 0.0, then run php artisan config:clear after editing .env. Cached
config is the usual culprit.
Log::error() produces no event. The bugwatch channel is missing from config/logging.php, or
LOG_CHANNEL points at a channel or stack that does not include it. Also check BUGWATCH_LEVEL is not
above the level you are logging.
Static BugWatch:: calls do nothing. The static facade holds its own client and self disables unless
you call BugWatch::init(). The provider only binds NewInstance\BugWatch\Client in the container. In
Laravel, inject or resolve Client (as in the job above) rather than using the static facade.
Double reporting. An exception both caught and logged to the bugwatch channel and then rethrown will
be captured twice, once by the log handler and once by the exception handler decorator. Either do not
rethrow after logging, or set BUGWATCH_CAPTURE_EXCEPTIONS=false and rely on the log channel alone.
Worker loop leakage. Users or tags from job A showing up on job B means something bypassed the reset:
a custom queue:work wrapper that does not fire JobProcessed, or a looping Artisan command (only
flushed, never reset). Call $client->resetScope() at your own boundary, or skip shared scope entirely
and pass user and tags per capture through captureException($e, ['user' => ..., 'tags' => ...]).